From defba6eea7d4b92db62423f1adc99ef6f6f4ceb0 Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Tue, 6 Oct 2026 16:26:24 -0400 Subject: [PATCH] Peergos account linking (ownership-proven), billing-sourced quotas (quota-admin hook), customer flags - POST /portal/api/peergos/challenge|signup|link, GET /links, /link-status/:username ownership proof: single-use challenge signed with the Peergos identity key, verified against the key embedded in the owner CID; refuses usernames linked to another customer and names not registered on our Peergos server - staff: GET /peergos/links[/:username], DELETE /peergos/links/:username (unlink) - /quota-admin/* (Peergos HttpQuotaAdmin, CBOR): quotas from the linked tier (Free 1 / Pro 50 / Business 500 GiB), internal grokbot* 5 GiB, unlinked 1 GiB with grace, floored at imported legacy quota and staff overrides - staff quota routes: list/import/override - customer_flags (test/noInvoice); invoice generation skips noInvoice - /usage users carry quotaBytes - additive tables: peergos_links, peergos_identity_keys, peergos_quota_users, peergos_signup_tokens, peergos_quota_overrides, customer_flags - 23 new tests (313 total) --- docs/ENV-MATRIX.md | 12 + docs/PEERGOS-BILLING.md | 97 +++- docs/USAGE-BILLING-API.md | 4 + src/admin.ts | 7 + src/app.ts | 64 ++- src/billing-api.ts | 20 +- src/customer-flags.ts | 137 ++++++ src/index.ts | 22 + src/peergos-identity.ts | 334 +++++++++++++ src/peergos-link.ts | 457 ++++++++++++++++++ src/peergos-quota.ts | 698 ++++++++++++++++++++++++++++ src/portal.ts | 127 ++--- src/staff-iam.ts | 3 + tests/customer-flags.test.ts | 30 ++ tests/helpers/cbor-client.ts | 26 ++ tests/helpers/peergos-keys.ts | 33 ++ tests/peergos-identity.test.ts | 91 ++++ tests/peergos-link.test.ts | 197 ++++++++ tests/peergos-quota.test.ts | 178 +++++++ tests/peergos-sqlite-stores.test.ts | 51 ++ 20 files changed, 2527 insertions(+), 61 deletions(-) create mode 100644 src/customer-flags.ts create mode 100644 src/peergos-identity.ts create mode 100644 src/peergos-link.ts create mode 100644 src/peergos-quota.ts create mode 100644 tests/customer-flags.test.ts create mode 100644 tests/helpers/cbor-client.ts create mode 100644 tests/helpers/peergos-keys.ts create mode 100644 tests/peergos-identity.test.ts create mode 100644 tests/peergos-link.test.ts create mode 100644 tests/peergos-quota.test.ts create mode 100644 tests/peergos-sqlite-stores.test.ts diff --git a/docs/ENV-MATRIX.md b/docs/ENV-MATRIX.md index 906b56e..10a1286 100644 --- a/docs/ENV-MATRIX.md +++ b/docs/ENV-MATRIX.md @@ -13,3 +13,15 @@ **BusinessStore (WP-L / WP-V2):** `BUSINESS_STORE=sqlite-file` (default) uses `BILLING_DB`; `BUSINESS_STORE=libsql` requires `LIBSQL_URL` (fail closed). Meter/customer/usage go through BusinessStore adapters. Pricing, channel rates, and invoices also come from the BusinessStore path (`SqliteFileBusinessStore` getters on the same `BILLING_DB`); libsql driver creates matching schema but dual-runs those sync repos on `BILLING_DB` until async libsql repos land. **Dual-writer freeze (2026-10-03):** Meter and balance **writes** go only through verae-edge-billing → BusinessStore (`billing.db` today, libSQL after cutover). Do not call `verae.billing.balance.adjust` on `:3010` for production adjusts until it is a libSQL-backed facade. + +## Peergos quota hook (2026-10-06) + +| Variable | Default | Meaning | +|---|---|---| +| `PEERGOS_URL` | `https://peergos.verae.ai` | Owner and key lookups | +| `PEERGOS_INTERNAL_USERS` | `peergos,verae-admin,verae-ops` | Internal accounts (internal quota when unlinked) | +| `PEERGOS_INTERNAL_PREFIXES` | `grokbot` | Username prefixes treated as internal/test | +| `PEERGOS_INTERNAL_QUOTA_BYTES` | 5368709120 | Quota for unlinked internal users | +| `PEERGOS_UNLINKED_QUOTA_BYTES` | 1073741824 | Quota for other unlinked users | +| `PEERGOS_LINK_GRACE_DAYS` | 30 | Reported link grace period | +| `PEERGOS_MAX_USERS` | 100000 | Signup cap (replaces Peergos `-max-users` in paid mode) | diff --git a/docs/PEERGOS-BILLING.md b/docs/PEERGOS-BILLING.md index ebb132e..ad9e33a 100644 --- a/docs/PEERGOS-BILLING.md +++ b/docs/PEERGOS-BILLING.md @@ -85,12 +85,12 @@ These are decided but **not implemented in this code yet**. The design is in |---|----------| | D1 | **Stripe customers** pay for storage through a **Stripe subscription**: a storage base price item plus a metered storage overage item. Any prepaid balance is applied first (D7). **PO customers** stay on the existing invoice run (Generate → Issue with PO number and 30-day due date). | | D2 | The timestamp tier's **monthly credit counts against storage charges** (base and overage), as `buildInvoice` already does for all lines. | -| D3 | **Every Peergos user must have a Verae (zap.verae.ai) customer account.** App signup creates or links one. There are no unlinked users, so the `cust-platform` default above is to be retired. | +| D3 | **Every Peergos user must have a Verae (zap.verae.ai) customer account.** App signup creates or links one. There are no unlinked users, so the `cust-platform` default above is to be retired. **Implemented 2026-10-06**: see *Account linking* below. Accounts created before then stay unlinked until their owner links them, with a 1 GiB quota during a 30-day grace period. | | D4 | **Partial months are prorated** (storage base fee and included GB). | | D5 | **Storage overage markup is 25% (×1.25)**, matching `DEFAULT_STORAGE_OVERAGE_MARKUP_BPS = 2500`. | | D6 | **Hitting an overage level never blocks storage** for accounts in good standing (D11 covers non-payment). Usage continues and overage is billed at month end. Peergos quotas are raised automatically ahead of usage. | | D7 | **Hybrid prepaid + subscription.** Keep both. The prepaid balance (`balance_cents`) is applied first, as a negative item on the Stripe draft invoice keyed by invoice id. The subscription bills only the remainder, with no double billing. Portal Reload stays. | -| D8 | **New app signups start on the Free tier** (1 GB included, $1.00 monthly credit, 20 stamps), with **no card required**. Overage is invoiced at month end. Access is restricted while an invoice is unpaid (D11). | +| D8 | **New app signups start on the Free tier** (1 GB included, $1.00 monthly credit, 20 stamps), with **no card required**. **Implemented 2026-10-06** (`POST /portal/api/peergos/signup`, Free quota from the quota hook). Overage is invoiced at month end. Access is restricted while an invoice is unpaid (D11). | | D9 | **Restarting the ns2 (137) Peergos container is allowed at any time until George says real customers are live.** This is recorded only. | | D10 | **Runaway usage triggers alerts only, with no hard ceiling.** Alerts go to George by default, through the admin portal and email (`STORAGE_ALERT_EMAILS`). The thresholds are in the design doc §3.12. | | D11 | **Failed or unpaid payment blocks reads as well as writes** (revised by George on 2026-10-06; it replaces the earlier quota-pin plan). A delinquent customer's Peergos users cannot log in, read, download, write, or serve public or secret links until they pay. Enforcement is the access gate below, which is **implemented and live**. The app shows "Access is paused until this invoice is paid" with Pay now. | @@ -147,3 +147,96 @@ in `peergos_owner_keys`. * `s3.peergos.verae.ai` (SeaweedFS S3) is a separate vhost and is not gated; it needs S3 credentials. * **Links:** public and secret links are blocked by default, as George asked, because they fetch through the same owner-tagged API. There is no per-link exemption. * **Stripe-side failures:** there is no Stripe webhook yet, so a failed subscription renewal or card charge outside pay-now does not create a hold. Only overdue invoices and pay-now failures do. + + +## Account linking (D3, live since 2026-10-06) + +The app's signup has a required "Your Verae account" step. It creates a Free-plan customer +(no card) or signs in to an existing one, and links the new Peergos username to it as the +`peergos:` sub-account (`parent_id` = the customer). + +**Ownership proof.** Nobody can link a username they cannot sign in to: +1. `POST /portal/api/peergos/challenge {peergosUsername, purpose: "signup"|"link"}` returns + `{challengeId, message, expiresAt}`. The challenge is single use, lasts 10 minutes and is tied + to that username and purpose. The username must exist on our Peergos server: it must be in the + quota hook's user table (see *Quotas*). +2. The app signs `message` with the user's **Peergos identity key** (`crypto_sign`: signature then + message, hex). The key is only available after a Peergos login. The Rust bridge function is + `sign_billing_link_challenge(session, message)`. +3. The server resolves `username → owner` (`core/getPublicKey`, cached in `peergos_owner_keys`). It + takes the Ed25519 key from the owner CID itself; Peergos owners are identity-multihash CIDs that + embed the key block. For sha2-256 owner CIDs it fetches the block and checks the hash. It then + verifies the signature and requires the signed text to equal the challenge exactly. +4. A username already linked to a different customer is refused (`409 + peergos_user_linked_to_another_account`). Only staff can unlink. + +The message format is `verae-billing-link/v1\nusername=\npurpose=

\nchallenge=\nexpires=\nservice=zap.verae.ai`. + +**Routes** (`/portal/api/peergos`, mounted before the session-protected portal routes): + +| Route | Auth | What it does | +|---|---|---| +| `POST /challenge` | public, 30 per 15 min per IP | Issues a challenge (see above). Errors: `invalid_peergos_username`, `peergos_user_not_found`, `peergos_user_not_on_this_server`. | +| `POST /signup` `{name, email, password, peergosUsername, challengeId, signature}` | public, 10 per 15 min per IP | Validates the fields first (password policy, duplicate email → 409), then verifies the proof, creates a **Free** customer (no card, $50 signup credit as with portal signup), links the user and returns `{token, customer, link}`. `token` is a normal portal session. | +| `POST /link` `{peergosUsername, challengeId, signature}` | `Authorization: Bearer ` | Links the proven user to the signed-in customer. Use it after `POST /portal/api/login` (TOTP supported) for "I already have an account". | +| `GET /links` | Bearer | The customer's linked Peergos users with `quotaBytes`, `quotaBasis` and `tierId`. | +| `GET /link-status/:username` | public, 120 per 15 min per IP | `{username, linked}` only. | + +**Staff routes** (`/admin/api`; writes need `admin.customers` when IAM is on): +* `GET /peergos/links` (audit log, including unlinked rows) +* `GET /peergos/links/:username` (link view plus quota) +* `DELETE /peergos/links/:username` **unlinks**: it clears `parent_id` on `peergos:` and + stamps `unlinked_at_ms`/`unlinked_by`. The user falls back to the unlinked quota. +* `GET /customer-flags`, `GET|PUT /customers/:id/flags {test?, noInvoice?, note?}`. Invoice + generation skips customers flagged `noInvoice`, and their sub-accounts. Use this for e2e test + customers. + +Tables (additive, on billing.db): `peergos_links`, `peergos_identity_keys`, `customer_flags`. + +## Quotas: billing is the source of truth (live since 2026-10-06) + +Peergos on ns2 runs with `-quota-admin-address /ip4/70.88.205.138/tcp/3041` (upstream paid mode, +`HttpQuotaAdmin`). Peergos no longer reads its local `quotas.sql`. It asks this service for every +quota, the list of local users, whether a signup is allowed, and signup tokens. In paid mode +`-default-quota` and `-max-users` are ignored; `PEERGOS_MAX_USERS` here replaces them. + +**Quota per user** (GiB = 2^30), computed by `PeergosQuotaService` and cached for 15 s: + +| User | Quota | +|---|---| +| Linked (`peergos:` child with a parent) | The parent's tier included storage: Free 1, Pro 50, Business 500 GiB. A customer `includedStorageGbOverride` wins. | +| Internal/test, not linked (`PEERGOS_INTERNAL_USERS` = peergos, verae-admin, verae-ops; `PEERGOS_INTERNAL_PREFIXES` = grokbot) | `PEERGOS_INTERNAL_QUOTA_BYTES` (5 GiB) | +| Any other known user, not linked | `PEERGOS_UNLINKED_QUOTA_BYTES` (1 GiB). `graceEndsAt` (first seen + `PEERGOS_LINK_GRACE_DAYS`, 30) is reported in link views. The quota does **not** change after the grace period yet; freezing is open (design N5). | +| Removed (`quota-remove`) or unknown | 0 | + +Every result is floored at the user's **legacy quota** (imported from `quotas.sql` at the switch, so +no existing account lost space) and at any staff **override** (`PUT +/admin/api/peergos/quotas/:username/override {quotaBytes, reason}`, the auto-raise hook for D6). +Peergos caches quotas for up to an hour in its space check, so a tier change can take that long to +show in uploads. `get_quota` from the app asks directly. + +**`/quota-admin/*`** answers the upstream calls in CBOR: `signups`, `allowed`, `token-add|remove|list|delete`, +`quota-by-name`, `quota-by-name-time`, `quota-remove`, `usernames`, `quota` and `payment-properties` +(both check the owner's signed time against the owner key; Peergos does not check them in paid mode), +`request`, `create-paid` and `remove-desired-quota`. `request` verifies the signed SpaceRequest and +answers with the current quota plus a message pointing to Usage & Billing. Plan changes happen in +Verae, not in Peergos. `create-paid` is refused. Errors are 400 with the message in the `Trailer` +header, as `HttpQuotaAdmin` expects. Without the `X-Verae-Quota-Relay: 1` header the routes return 404. + +**Network.** ns1 Caddy listens on `http://70.88.205.138:3041`. It only accepts 70.88.205.137 (ns2) +and loopback, only serves `/quota-admin/*`, sets `X-Verae-Quota-Relay: 1` and proxies to +127.0.0.1:3040. `zap.verae.ai` answers 404 on `/quota-admin/*`. + +**Coupling.** Peergos logins, writes, signups and quota checks now depend on verae-edge-billing. +A billing restart briefly fails those calls; Peergos retries on the next request. + +**Staff quota routes:** `GET /peergos/quotas`, `GET /peergos/quotas/:username`, +`POST /peergos/quotas/import {users:[{username, quotaBytes}], tokens:[...]}` (used once at the switch; +idempotent, only raises legacy quotas), `PUT|DELETE /peergos/quotas/:username/override`. + +Tables (additive): `peergos_quota_users`, `peergos_signup_tokens`, `peergos_quota_overrides`. + +**Rollback** (back to Peergos-local quotas): remove `-quota-admin-address` from the compose +command and recreate the container. `quotas.sql` was left untouched. Users who signed up in paid mode +are not in it; add them with `GET /admin/api/peergos/quotas` and Peergos `-quotas-init-file` or the +quota CLI. diff --git a/docs/USAGE-BILLING-API.md b/docs/USAGE-BILLING-API.md index c28a3c7..6f07165 100644 --- a/docs/USAGE-BILLING-API.md +++ b/docs/USAGE-BILLING-API.md @@ -43,6 +43,10 @@ Card routes return **503 `stripe_unavailable`** when no real `STRIPE_SECRET_KEY` Gate endpoint: `GET /peergos-gate/check` with `X-Forwarded-Uri` returns 200 (allowed) or 402 (paused). It is called by ns2 Caddy `forward_auth`, and ns1 Caddy restricts it to ns2. See `docs/PEERGOS-BILLING.md`. +## Peergos quota in `/usage` + +Each `storage.users[]` entry has `quotaBytes`: the user's current Peergos quota from the quota hook (see PEERGOS-BILLING.md, *Quotas*). Linking and signup routes are under `/portal/api/peergos` (PEERGOS-BILLING.md, *Account linking*). + ## Storage (additive) `billing.db` gains two tables, created with `CREATE TABLE IF NOT EXISTS` at startup: * `storage_samples(id, customer_id, peergos_username, bytes, sampled_at_ms)` diff --git a/src/admin.ts b/src/admin.ts index 7460b0a..ede0419 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -126,6 +126,8 @@ export interface AccountingDeps { users: AdminUserRepo; /** Outbound email for admin password codes; defaults to SMTP env config. */ mailer?: Mailer; + /** customer_flags: invoice generation skips customers flagged noInvoice. */ + flags?: import('./customer-flags').CustomerFlagRepo; } const PERIOD_RE = /^\d{4}-(0[1-9]|1[0-2])$/; @@ -555,6 +557,11 @@ export function adminRouter( for (const customer of await customers.list()) { if (customerId && customer.id !== customerId) continue; + const noInvoice = accounting.flags?.get(customer.id)?.noInvoice || (customer.parentId && accounting.flags?.get(customer.parentId)?.noInvoice); + if (noInvoice) { + skipped.push({ customerId: customer.id, reason: 'flagged no_invoice' }); + continue; + } const existing = accounting.invoices .list({ customerId: customer.id, period }) .find((i) => i.status !== 'draft'); diff --git a/src/app.ts b/src/app.ts index 794e533..72f756d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -53,6 +53,16 @@ import { BILLING_SUBJECTS, natsAdjust, natsPublish, natsStatement } from './bill import { buildRegistrationPdf, registrationFilename } from './registration-pdf'; import { ApiTokenRepo, InMemoryApiTokenRepo } from './db/api-token-repo'; import { seoRouter } from './seo'; +import { CustomerFlagRepo, customerFlagsAdminRouter, InMemoryCustomerFlagRepo } from './customer-flags'; +import { IdentityKeyLookup, peergosIdentityKeys } from './peergos-identity'; +import { InMemoryPeergosLinkRepo, PeergosLinkRepo, peergosLinkRouter, PeergosLinkService, peergosLinkStaffRouter } from './peergos-link'; +import { + InMemoryPeergosQuotaStore, + peergosQuotaAdminRouter, + PeergosQuotaService, + peergosQuotaStaffRouter, + PeergosQuotaStore, +} from './peergos-quota'; /** * Parse export formats from body/query. @@ -267,6 +277,14 @@ export interface AppDeps { /** Stripe client for card management / pay-now; undefined → 503 on those routes. */ billingStripe?: BillingStripe; stripePublishableKey?: string; + /** Peergos external quota hook + account linking (additive tables on billing.db in production). */ + peergosQuota?: PeergosQuotaService; + peergosQuotaStore?: PeergosQuotaStore; + peergosLinks?: PeergosLinkRepo; + peergosIdentityKeys?: IdentityKeyLookup; + customerFlags?: CustomerFlagRepo; + /** Test hook: relay header requirement on /quota-admin (default true). */ + quotaAdminRequireRelayHeader?: boolean; } const SPEC_PATH = path.join(PROJECT_ROOT, 'openapi.yaml'); @@ -384,6 +402,43 @@ export function buildApp(deps: AppDeps = {}): { // Caddy forward_auth target for peergos.verae.ai (design §3.11). Mounted before // the OpenAPI validator; ns1 Caddy only lets ns2 reach it. app.use('/peergos-gate', peergosGateRouter(peergosGate, gateDeps)); + const resolveOwner = deps.resolvePeergosOwner ?? (async () => undefined); + const identityKeys: IdentityKeyLookup = + deps.peergosIdentityKeys ?? peergosIdentityKeys(process.env.PEERGOS_URL ?? 'https://peergos.verae.ai'); + const peergosQuota = + deps.peergosQuota ?? + new PeergosQuotaService({ + store: deps.peergosQuotaStore ?? new InMemoryPeergosQuotaStore(), + customers, + tiers: () => pricingStore.getTiers(), + resolveOwner, + identityKeys, + log: (m: string) => console.warn(m), + }); + // Peergos external quota hook (-quota-admin-address, paid mode). ns1 Caddy relays + // only ns2's calls on a dedicated listener and sets X-Verae-Quota-Relay. + app.use( + '/quota-admin', + peergosQuotaAdminRouter(peergosQuota, { requireRelayHeader: deps.quotaAdminRequireRelayHeader ?? true }), + ); + const peergosLinks = deps.peergosLinks ?? new InMemoryPeergosLinkRepo(); + const sessions = deps.sessions ?? new InMemorySessionRepo(); + const linkDeps = { + customers, + sessions, + credits, + links: peergosLinks, + resolveOwner, + identityKeys, + quota: peergosQuota, + onChange: () => { + peergosQuota.invalidate(); + peergosGate.invalidate(); + }, + }; + let linkSvc: PeergosLinkService | undefined; + const peergosLinkService = () => (linkSvc ??= new PeergosLinkService(linkDeps)); + const customerFlags = deps.customerFlags ?? new InMemoryCustomerFlagRepo(); const billingBase = { gate: peergosGate, holds: billingHolds, @@ -391,6 +446,7 @@ export function buildApp(deps: AppDeps = {}): { purchaseOrders, stripe: deps.billingStripe, publishableKey: deps.stripePublishableKey, + peergosQuota: async (u: string) => (await peergosQuota.decide(u)).quotaBytes, }; app.use( '/admin/api', @@ -400,11 +456,14 @@ export function buildApp(deps: AppDeps = {}): { adminRouter( pricingStore, customers, - { usage, invoices, users: adminUsers, mailer: deps.mailer }, + { usage, invoices, users: adminUsers, mailer: deps.mailer, flags: customerFlags }, credits, deps.channelRates, ), peergosGateAdminRouter(peergosGate, gateDeps), + peergosQuotaStaffRouter(peergosQuota), + peergosLinkStaffRouter(peergosLinkService(), peergosLinks), + customerFlagsAdminRouter(customerFlags, customers), billingAdminRouter({ ...billingBase, customers, @@ -416,7 +475,6 @@ export function buildApp(deps: AppDeps = {}): { app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin'))); app.use('/client', express.static(path.join(PROJECT_ROOT, 'client'))); - const sessions = deps.sessions ?? new InMemorySessionRepo(); const apiTokens: ApiTokenRepo = deps.apiTokens ?? new InMemoryApiTokenRepo(); const qr = deps.qr ?? @@ -428,6 +486,8 @@ export function buildApp(deps: AppDeps = {}): { // Safe default for tests/dev: credits immediately, no Stripe call. reload: async (_customer, amountCents) => ({ mode: 'dev', creditedCents: amountCents }), }; + // App signup: Free customer + proven Peergos link (before the session-protected portal). + app.use('/portal/api/peergos', peergosLinkRouter(peergosLinkService(), linkDeps)); app.use( '/portal/api', portalRouter({ diff --git a/src/billing-api.ts b/src/billing-api.ts index bd7a39f..656e80a 100644 --- a/src/billing-api.ts +++ b/src/billing-api.ts @@ -498,6 +498,8 @@ export interface BillingApiDeps { /** Peergos access gate: delinquency drives `standing` and pay-now clears holds. */ gate?: PeergosGateIndex; holds?: BillingHoldRepo; + /** Peergos quota (bytes) per username from the quota service; adds `quotaBytes` to /usage users. */ + peergosQuota?: (username: string) => Promise; } const STORAGE_ENDPOINT_IDS = new Set(['storage-gb-month']); @@ -620,11 +622,19 @@ export function billingRouter(deps: BillingApiDeps): Router { pooledBytes: s.currentBytes, includedGb, percentOfPlan: includedGb > 0 ? Math.round((s.currentBytes / GIB / includedGb) * 1000) / 10 : null, - users: s.users.map((child) => { - const name = child.id.replace(/^peergos:/, ''); - const last = s.latest.get(name); - return { username: name, bytes: last?.bytes ?? null, sampledAt: last ? new Date(last.sampledAtMs).toISOString() : null }; - }), + users: await Promise.all( + s.users.map(async (child) => { + const name = child.id.replace(/^peergos:/, ''); + const last = s.latest.get(name); + const quotaBytes = deps.peergosQuota ? ((await deps.peergosQuota(name).catch(() => undefined)) ?? null) : undefined; + return { + username: name, + bytes: last?.bytes ?? null, + sampledAt: last ? new Date(last.sampledAtMs).toISOString() : null, + ...(quotaBytes !== undefined ? { quotaBytes } : {}), + }; + }), + ), daily: s.daily, source: s.samples.length ? 'samples' : 'none', }, diff --git a/src/customer-flags.ts b/src/customer-flags.ts new file mode 100644 index 0000000..1c28f0a --- /dev/null +++ b/src/customer-flags.ts @@ -0,0 +1,137 @@ +import type Database from 'better-sqlite3'; +import { Router } from 'express'; +import { CustomerRepo } from './auth'; + +/** + * Per-customer operational flags (additive table `customer_flags`). + * - test: internal/test customer (e.g. end-to-end signup tests) + * - noInvoice: /admin/api/invoices/generate skips this customer + */ +export interface CustomerFlags { + customerId: string; + test: boolean; + noInvoice: boolean; + note?: string; + updatedAtMs: number; + updatedBy?: string; +} + +export interface CustomerFlagRepo { + get(customerId: string): CustomerFlags | undefined; + set(f: CustomerFlags): void; + list(): CustomerFlags[]; +} + +export const CUSTOMER_FLAGS_DDL = ` +CREATE TABLE IF NOT EXISTS customer_flags ( + customer_id TEXT PRIMARY KEY, + test INTEGER NOT NULL DEFAULT 0, + no_invoice INTEGER NOT NULL DEFAULT 0, + note TEXT, + updated_at_ms INTEGER NOT NULL, + updated_by TEXT +); +`; + +export class InMemoryCustomerFlagRepo implements CustomerFlagRepo { + private rows = new Map(); + get(id: string) { + const r = this.rows.get(id); + return r ? { ...r } : undefined; + } + set(f: CustomerFlags) { + this.rows.set(f.customerId, { ...f }); + } + list() { + return [...this.rows.values()].map((r) => ({ ...r })); + } +} + +export class SqliteCustomerFlagRepo implements CustomerFlagRepo { + constructor(private db: Database.Database) { + db.exec(CUSTOMER_FLAGS_DDL); + } + private map(r: Record): CustomerFlags { + return { + customerId: r.customer_id as string, + test: !!r.test, + noInvoice: !!r.no_invoice, + note: (r.note as string | null) ?? undefined, + updatedAtMs: r.updated_at_ms as number, + updatedBy: (r.updated_by as string | null) ?? undefined, + }; + } + get(id: string) { + const r = this.db.prepare('SELECT * FROM customer_flags WHERE customer_id = ?').get(id) as + | Record + | undefined; + return r ? this.map(r) : undefined; + } + set(f: CustomerFlags) { + this.db + .prepare( + `INSERT INTO customer_flags (customer_id, test, no_invoice, note, updated_at_ms, updated_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(customer_id) DO UPDATE SET test = excluded.test, no_invoice = excluded.no_invoice, + note = excluded.note, updated_at_ms = excluded.updated_at_ms, updated_by = excluded.updated_by`, + ) + .run(f.customerId, f.test ? 1 : 0, f.noInvoice ? 1 : 0, f.note ?? null, f.updatedAtMs, f.updatedBy ?? null); + } + list() { + return (this.db.prepare('SELECT * FROM customer_flags ORDER BY customer_id').all() as Record[]).map( + (r) => this.map(r), + ); + } +} + +function view(id: string, f?: CustomerFlags) { + return { + customerId: id, + test: f?.test ?? false, + noInvoice: f?.noInvoice ?? false, + note: f?.note ?? null, + updatedAt: f ? new Date(f.updatedAtMs).toISOString() : null, + updatedBy: f?.updatedBy ?? null, + }; +} + +/** Staff routes (mounted in /admin/api): GET /customer-flags, GET|PUT /customers/:id/flags. */ +export function customerFlagsAdminRouter(flags: CustomerFlagRepo, customers: CustomerRepo, now: () => number = Date.now): Router { + const router = Router(); + router.get('/customer-flags', (_req, res) => { + res.json({ flags: flags.list().map((f) => view(f.customerId, f)) }); + }); + router.get('/customers/:id/flags', (req, res) => { + res.json(view(req.params.id, flags.get(req.params.id))); + }); + router.put('/customers/:id/flags', async (req, res) => { + const c = await customers.findById(req.params.id); + if (!c) { + res.status(404).json({ error: 'customer not found' }); + return; + } + const prev = flags.get(c.id); + const b = req.body ?? {}; + for (const k of ['test', 'noInvoice'] as const) { + if (b[k] !== undefined && typeof b[k] !== 'boolean') { + res.status(400).json({ error: `${k} must be boolean` }); + return; + } + } + if (b.note !== undefined && b.note !== null && typeof b.note !== 'string') { + res.status(400).json({ error: 'note must be a string' }); + return; + } + const next: CustomerFlags = { + customerId: c.id, + test: b.test ?? prev?.test ?? false, + noInvoice: b.noInvoice ?? prev?.noInvoice ?? false, + note: b.note === null ? undefined : (b.note ?? prev?.note), + updatedAtMs: now(), + updatedBy: req.staff?.username ?? 'admin', + }; + flags.set(next); + res.json(view(c.id, next)); + }); + return router; +} diff --git a/src/index.ts b/src/index.ts index 0306881..3bce0da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,10 @@ import { stripeBillingClient, } from './billing-api'; import { peergosOwnerResolver, SqliteBillingHoldRepo } from './peergos-gate'; +import { SqliteCustomerFlagRepo } from './customer-flags'; +import { peergosIdentityKeys } from './peergos-identity'; +import { SqlitePeergosLinkRepo } from './peergos-link'; +import { PeergosQuotaService, quotaPolicyFromEnv, SqlitePeergosQuotaStore } from './peergos-quota'; loadEnv({ path: path.join(PROJECT_ROOT, '.env') }); @@ -63,6 +67,20 @@ async function main(): Promise { // Peergos access gate: holds + username->owner cache (additive tables on billing.db). const billingHolds = new SqliteBillingHoldRepo(opened.billingDb); const resolvePeergosOwner = peergosOwnerResolver(process.env.PEERGOS_URL ?? 'https://peergos.verae.ai', opened.billingDb); + // Peergos quota hook + account links + customer flags (additive tables on billing.db). + const peergosUrl = process.env.PEERGOS_URL ?? 'https://peergos.verae.ai'; + const peergosIdentity = peergosIdentityKeys(peergosUrl, opened.billingDb); + const peergosQuota = new PeergosQuotaService({ + store: new SqlitePeergosQuotaStore(opened.billingDb), + customers, + tiers: () => pricingStore.getTiers(), + policy: quotaPolicyFromEnv(), + resolveOwner: resolvePeergosOwner, + identityKeys: peergosIdentity, + log: (m) => console.warn(m), + }); + const peergosLinks = new SqlitePeergosLinkRepo(opened.billingDb); + const customerFlags = new SqliteCustomerFlagRepo(opened.billingDb); const billingStripe = hasRealStripeKey(process.env.STRIPE_SECRET_KEY) ? stripeBillingClient(process.env.STRIPE_SECRET_KEY) : undefined; @@ -85,6 +103,10 @@ async function main(): Promise { peergosGateOptions: { blockOnUnpaid: process.env.PEERGOS_BLOCK_ON_UNPAID === '1' }, billingStripe, stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY, + peergosQuota, + peergosLinks, + peergosIdentityKeys: peergosIdentity, + customerFlags, }); const port = Number(process.env.PORT ?? 3000); const bind = process.env.BIND ?? '127.0.0.1'; diff --git a/src/peergos-identity.ts b/src/peergos-identity.ts new file mode 100644 index 0000000..87599c7 --- /dev/null +++ b/src/peergos-identity.ts @@ -0,0 +1,334 @@ +import { createHash, createPublicKey, verify as cryptoVerify } from 'crypto'; +import type Database from 'better-sqlite3'; + +/** + * Peergos identity-key helpers: prove that a request was signed by the owner of a + * Peergos account, using only public data from the Peergos server. + * + * - A Peergos owner (`owner=` in the API, e.g. `z59vuwzf…`) is a CIDv1 (dag-cbor, + * sha2-256) in multibase base58btc. The CID's digest is the sha256 of the + * owner's public-key block, so the key is self-certifying. + * - The key block is CBOR `[1, bytes(32)]` (PublicSigningKey type 1 = Ed25519). + * - Peergos signs with TweetNaCl `crypto_sign`: signed = signature(64) || message. + */ + +/* ---------------- base58btc ---------------- */ + +const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +const B58_MAP = new Map([...B58].map((c, i) => [c, i])); + +export function base58btcDecode(s: string): Uint8Array { + let zeros = 0; + while (zeros < s.length && s[zeros] === '1') zeros++; + const bytes: number[] = []; + for (const ch of s) { + const v = B58_MAP.get(ch); + if (v === undefined) throw new Error('invalid base58 character'); + let carry = v; + for (let j = 0; j < bytes.length; j++) { + carry += bytes[j] * 58; + bytes[j] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + const out = new Uint8Array(zeros + bytes.length); + bytes.reverse().forEach((b, i) => (out[zeros + i] = b)); + return out; +} + +function readVarint(buf: Uint8Array, pos: number): [number, number] { + let x = 0; + let shift = 0; + for (;;) { + if (pos >= buf.length) throw new Error('truncated varint'); + const b = buf[pos++]; + x += (b & 0x7f) * 2 ** shift; + if ((b & 0x80) === 0) return [x, pos]; + shift += 7; + if (shift > 49) throw new Error('varint too long'); + } +} + +/** + * What a Peergos owner CID commits to. Peergos identity keys are small, so their + * CIDs normally use the identity multihash (0x00): the CBOR key block [1, key32] + * is embedded in the owner string itself (grokbot1's owner is such a CID). Larger + * blocks use sha2-256 and must be fetched and hash-checked. + */ +export function parseOwnerCid(owner: string): { inlineBlock?: Buffer; sha256?: Buffer } { + if (!/^z[1-9A-HJ-NP-Za-km-z]{20,120}$/.test(owner)) throw new Error('owner must be base58btc multibase'); + const cid = base58btcDecode(owner.slice(1)); + let [version, p] = readVarint(cid, 0); + if (version !== 1) throw new Error('owner is not a CIDv1'); + [, p] = readVarint(cid, p); // codec (dag-cbor 0x71 in practice) + let hashFn: number; + let len: number; + [hashFn, p] = readVarint(cid, p); + [len, p] = readVarint(cid, p); + if (cid.length !== p + len) throw new Error('owner CID length mismatch'); + if (hashFn === 0x00) return { inlineBlock: Buffer.from(cid.subarray(p)) }; + if (hashFn === 0x12 && len === 32) return { sha256: Buffer.from(cid.subarray(p)) }; + throw new Error('owner CID uses an unsupported multihash'); +} + +/** sha256 digest the owner CID commits to (sha2-256 CIDs only). */ +export function ownerKeyDigest(owner: string): Buffer { + const c = parseOwnerCid(owner); + if (!c.sha256) throw new Error('owner CID is not sha2-256'); + return c.sha256; +} + +/* ---------------- minimal CBOR (the subset Peergos uses here) ---------------- */ + +export type Cbor = + | { t: 'int'; v: bigint } + | { t: 'bytes'; v: Buffer } + | { t: 'text'; v: string } + | { t: 'list'; v: Cbor[] } + | { t: 'map'; v: Map } + | { t: 'bool'; v: boolean } + | { t: 'null' }; + +export const cbor = { + int: (v: number | bigint): Cbor => ({ t: 'int', v: BigInt(v) }), + text: (v: string): Cbor => ({ t: 'text', v }), + bytes: (v: Buffer): Cbor => ({ t: 'bytes', v }), + bool: (v: boolean): Cbor => ({ t: 'bool', v }), + list: (v: Cbor[]): Cbor => ({ t: 'list', v }), + map: (entries: Record): Cbor => ({ t: 'map', v: new Map(Object.entries(entries)) }), +}; + +function head(major: number, n: bigint): Buffer { + const m = major << 5; + if (n < 24n) return Buffer.from([m | Number(n)]); + if (n < 0x100n) return Buffer.from([m | 24, Number(n)]); + if (n < 0x10000n) { + const b = Buffer.alloc(3); + b[0] = m | 25; + b.writeUInt16BE(Number(n), 1); + return b; + } + if (n < 0x100000000n) { + const b = Buffer.alloc(5); + b[0] = m | 26; + b.writeUInt32BE(Number(n), 1); + return b; + } + const b = Buffer.alloc(9); + b[0] = m | 27; + b.writeBigUInt64BE(n, 1); + return b; +} + +export function cborEncode(x: Cbor): Buffer { + switch (x.t) { + case 'int': + return x.v >= 0n ? head(0, x.v) : head(1, -1n - x.v); + case 'bytes': + return Buffer.concat([head(2, BigInt(x.v.length)), x.v]); + case 'text': { + const b = Buffer.from(x.v, 'utf8'); + return Buffer.concat([head(3, BigInt(b.length)), b]); + } + case 'list': + return Buffer.concat([head(4, BigInt(x.v.length)), ...x.v.map(cborEncode)]); + case 'map': { + // Peergos builds maps from a TreeMap: keys in natural (UTF-16) order. + const keys = [...x.v.keys()].sort(); + return Buffer.concat([ + head(5, BigInt(keys.length)), + ...keys.flatMap((k) => [cborEncode(cbor.text(k)), cborEncode(x.v.get(k)!)]), + ]); + } + case 'bool': + return Buffer.from([x.v ? 0xf5 : 0xf4]); + case 'null': + return Buffer.from([0xf6]); + } +} + +export function cborDecode(buf: Buffer): Cbor { + let p = 0; + const arg = (info: number): bigint => { + if (info < 24) return BigInt(info); + if (info === 24) return BigInt(buf[p++]); + if (info === 25) { + const v = buf.readUInt16BE(p); + p += 2; + return BigInt(v); + } + if (info === 26) { + const v = buf.readUInt32BE(p); + p += 4; + return BigInt(v); + } + if (info === 27) { + const v = buf.readBigUInt64BE(p); + p += 8; + return v; + } + throw new Error('unsupported CBOR length'); + }; + const item = (depth: number): Cbor => { + if (depth > 16) throw new Error('CBOR too deep'); + if (p >= buf.length) throw new Error('truncated CBOR'); + const ib = buf[p++]; + const major = ib >> 5; + const info = ib & 0x1f; + switch (major) { + case 0: + return { t: 'int', v: arg(info) }; + case 1: + return { t: 'int', v: -1n - arg(info) }; + case 2: + case 3: { + const n = Number(arg(info)); + if (p + n > buf.length) throw new Error('truncated CBOR'); + const b = buf.subarray(p, p + n); + p += n; + return major === 2 ? { t: 'bytes', v: Buffer.from(b) } : { t: 'text', v: b.toString('utf8') }; + } + case 4: { + const n = Number(arg(info)); + const v: Cbor[] = []; + for (let i = 0; i < n; i++) v.push(item(depth + 1)); + return { t: 'list', v }; + } + case 5: { + const n = Number(arg(info)); + const v = new Map(); + for (let i = 0; i < n; i++) { + const k = item(depth + 1); + if (k.t !== 'text') throw new Error('non-text CBOR map key'); + v.set(k.v, item(depth + 1)); + } + return { t: 'map', v }; + } + case 6: + arg(info); // tag: return the tagged item + return item(depth + 1); + case 7: + if (info === 20) return { t: 'bool', v: false }; + if (info === 21) return { t: 'bool', v: true }; + if (info === 22) return { t: 'null' }; + throw new Error('unsupported CBOR simple value'); + } + throw new Error('bad CBOR'); + }; + const out = item(0); + if (p !== buf.length) throw new Error('trailing bytes after CBOR'); + return out; +} + +/* ---------------- Ed25519 ---------------- */ + +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); + +/** Raw 32-byte Ed25519 key from a Peergos PublicSigningKey block `[1, bytes32]`. */ +export function ed25519KeyFromBlock(block: Buffer): Buffer { + const c = cborDecode(block); + if (c.t !== 'list' || c.v.length !== 2) throw new Error('unexpected key block'); + const [type, key] = c.v; + if (type.t !== 'int' || type.v !== 1n) throw new Error('identity key is not Ed25519'); + if (key.t !== 'bytes' || key.v.length !== 32) throw new Error('bad Ed25519 key length'); + return key.v; +} + +/** crypto_sign_open: returns the message if `signed` = sig(64) || message verifies. */ +export function ed25519Open(publicKey: Buffer, signed: Buffer): Buffer | undefined { + if (signed.length < 64 || publicKey.length !== 32) return undefined; + const key = createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, publicKey]), format: 'der', type: 'spki' }); + const sig = signed.subarray(0, 64); + const msg = signed.subarray(64); + try { + return cryptoVerify(null, msg, key, sig) ? Buffer.from(msg) : undefined; + } catch { + return undefined; + } +} + +/* ---------------- identity key lookup (cached, self-certified) ---------------- */ + +export const PEERGOS_IDENTITY_KEYS_DDL = ` +CREATE TABLE IF NOT EXISTS peergos_identity_keys ( + owner TEXT PRIMARY KEY, + public_key_hex TEXT NOT NULL, + fetched_at_ms INTEGER NOT NULL +); +`; + +export type IdentityKeyLookup = (owner: string) => Promise; + +/** + * Returns an owner's Ed25519 key: decoded straight from the owner CID when it is + * an identity-multihash CID (the usual case), otherwise fetched from Peergos + * `api/v0/block/get` and checked so that sha256(block) equals the CID digest (a + * tampered or wrong block is rejected). Keys never change for a given owner, so they are cached for good. + */ +export function peergosIdentityKeys( + baseUrl: string, + db?: Database.Database, + fetcher: (url: string) => Promise = async (url) => { + const res = await fetch(url, { method: 'POST' }); + if (!res.ok) throw new Error(`block/get HTTP ${res.status}`); + return Buffer.from(await res.arrayBuffer()); + }, +): IdentityKeyLookup { + const base = baseUrl.replace(/\/$/, ''); + const mem = new Map(); + if (db) db.exec(PEERGOS_IDENTITY_KEYS_DDL); + return async (owner) => { + const parsed = parseOwnerCid(owner); + if (parsed.inlineBlock) return ed25519KeyFromBlock(parsed.inlineBlock); + const digest = parsed.sha256!; + const hit = mem.get(owner); + if (hit) return hit; + if (db) { + const r = db.prepare('SELECT public_key_hex FROM peergos_identity_keys WHERE owner = ?').get(owner) as + | { public_key_hex: string } + | undefined; + if (r) { + const k = Buffer.from(r.public_key_hex, 'hex'); + mem.set(owner, k); + return k; + } + } + const block = await fetcher( + `${base}/api/v0/block/get?arg=${encodeURIComponent(owner)}&owner=${encodeURIComponent(owner)}`, + ); + if (!createHash('sha256').update(block).digest().equals(digest)) { + throw new Error('identity key block does not match owner hash'); + } + const key = ed25519KeyFromBlock(block); + mem.set(owner, key); + db?.prepare( + `INSERT INTO peergos_identity_keys (owner, public_key_hex, fetched_at_ms) VALUES (?, ?, ?) + ON CONFLICT(owner) DO NOTHING`, + ).run(owner, key.toString('hex'), Date.now()); + return key; + }; +} + +/** + * Peergos TimeLimited.isAllowedTime: `signedTime` is CborLong(utc millis) signed + * by the owner. Returns the signed time, or throws. + */ +export async function verifySignedTime( + keys: IdentityKeyLookup, + owner: string, + signedTime: Buffer, + nowMs: number, + windowSeconds = 300, +): Promise { + const msg = ed25519Open(await keys(owner), signedTime); + if (!msg) throw new Error('bad signature'); + const c = cborDecode(msg); + if (c.t !== 'int') throw new Error('Invalid cbor for time in authorisation!'); + const t = Number(c.v); + if (Math.abs(nowMs - t) > windowSeconds * 1000) throw new Error('Stale auth time, is your clock accurate?'); + return t; +} diff --git a/src/peergos-link.ts b/src/peergos-link.ts new file mode 100644 index 0000000..d21a78b --- /dev/null +++ b/src/peergos-link.ts @@ -0,0 +1,457 @@ +import { randomBytes } from 'crypto'; +import type Database from 'better-sqlite3'; +import { Router } from 'express'; +import { SessionRepo } from './accounts'; +import { Customer, CustomerRepo } from './auth'; +import { CreditLedgerLike } from './credits'; +import { peergosCustomerId, provisionPeergosUser } from './peergos-billing'; +import { ed25519Open, IdentityKeyLookup } from './peergos-identity'; +import { PeergosQuotaService } from './peergos-quota'; +import { RateLimiter } from './password-reset'; +import { + clientIp, + DEFAULT_SESSION_TTL_MS, + portalSignup, + publicProfile, + sessionAuth, + validatePortalSignup, +} from './portal'; + +/** + * Link a Peergos username to a Verae customer (design §3.3, D3), from the app's + * signup flow. + * + * Ownership proof: the server issues a single-use challenge for one username; the + * app signs it with the user's Peergos identity key (crypto_sign, the same key + * Peergos uses for signed times); the server fetches that user's public key from + * Peergos (getPublicKey → owner CID → key block whose sha256 is the CID digest) + * and verifies the signature. Only someone holding the account's identity key, + * i.e. who can sign in to that Peergos account, can link it. A username already + * linked to a different customer is refused (409); only staff can unlink. + * + * Routes (mounted at /portal/api/peergos, before the session-protected portal): + * POST /challenge {peergosUsername, purpose: 'signup'|'link'} (public) + * POST /signup {name, email, password, peergosUsername, challengeId, signature} + * → new Free customer (no card) + link + session (public) + * GET /link-status/:username → {username, linked} (public) + * POST /link {peergosUsername, challengeId, signature} (Bearer session) + * GET /links → this customer's linked Peergos users + quota (Bearer session) + * Staff (mounted in /admin/api): GET /peergos/links, GET/DELETE /peergos/links/:username + */ + +export const LINK_MESSAGE_PREFIX = 'verae-billing-link/v1'; +const CHALLENGE_TTL_MS = 10 * 60 * 1000; +const USERNAME_RE = /^[a-z0-9][a-z0-9_.-]{0,31}$/; + +export type LinkPurpose = 'signup' | 'link'; + +export interface LinkChallenge { + id: string; + username: string; + purpose: LinkPurpose; + message: string; + expiresAtMs: number; +} + +export function linkMessage(c: Omit): string { + return [ + LINK_MESSAGE_PREFIX, + `username=${c.username}`, + `purpose=${c.purpose}`, + `challenge=${c.id}`, + `expires=${c.expiresAtMs}`, + 'service=zap.verae.ai', + ].join('\n'); +} + +export class LinkChallengeStore { + private items = new Map(); + constructor(private now: () => number = Date.now) {} + issue(username: string, purpose: LinkPurpose): LinkChallenge { + const t = this.now(); + if (this.items.size > 5000) for (const [k, v] of this.items) if (v.expiresAtMs <= t) this.items.delete(k); + const base = { id: randomBytes(16).toString('hex'), username, purpose, expiresAtMs: t + CHALLENGE_TTL_MS }; + const c = { ...base, message: linkMessage(base) }; + this.items.set(c.id, c); + return c; + } + /** Single use: removes the challenge whether or not it is still valid. */ + take(id: string): LinkChallenge | undefined { + const c = this.items.get(id); + this.items.delete(id); + if (!c || c.expiresAtMs <= this.now()) return undefined; + return c; + } +} + +/* ---------------- link audit log (additive table) ---------------- */ + +export interface PeergosLinkRecord { + username: string; + customerId: string; + owner?: string; + method: string; + linkedAtMs: number; + linkedBy?: string; + unlinkedAtMs?: number; + unlinkedBy?: string; +} + +export interface PeergosLinkRepo { + record(r: PeergosLinkRecord): void; + markUnlinked(username: string, atMs: number, by?: string): void; + get(username: string): PeergosLinkRecord | undefined; + list(): PeergosLinkRecord[]; +} + +export const PEERGOS_LINKS_DDL = ` +CREATE TABLE IF NOT EXISTS peergos_links ( + username TEXT PRIMARY KEY, + customer_id TEXT NOT NULL, + owner TEXT, + method TEXT NOT NULL, + linked_at_ms INTEGER NOT NULL, + linked_by TEXT, + unlinked_at_ms INTEGER, + unlinked_by TEXT +); +`; + +export class InMemoryPeergosLinkRepo implements PeergosLinkRepo { + rows = new Map(); + record(r: PeergosLinkRecord) { + this.rows.set(r.username, { ...r }); + } + markUnlinked(username: string, atMs: number, by?: string) { + const r = this.rows.get(username); + if (r) Object.assign(r, { unlinkedAtMs: atMs, unlinkedBy: by }); + } + get(username: string) { + const r = this.rows.get(username); + return r ? { ...r } : undefined; + } + list() { + return [...this.rows.values()].map((r) => ({ ...r })); + } +} + +export class SqlitePeergosLinkRepo implements PeergosLinkRepo { + constructor(private db: Database.Database) { + db.exec(PEERGOS_LINKS_DDL); + } + private map(r: Record): PeergosLinkRecord { + return { + username: r.username as string, + customerId: r.customer_id as string, + owner: (r.owner as string | null) ?? undefined, + method: r.method as string, + linkedAtMs: r.linked_at_ms as number, + linkedBy: (r.linked_by as string | null) ?? undefined, + unlinkedAtMs: (r.unlinked_at_ms as number | null) ?? undefined, + unlinkedBy: (r.unlinked_by as string | null) ?? undefined, + }; + } + record(r: PeergosLinkRecord) { + this.db + .prepare( + `INSERT INTO peergos_links (username, customer_id, owner, method, linked_at_ms, linked_by, unlinked_at_ms, unlinked_by) + VALUES (?, ?, ?, ?, ?, ?, NULL, NULL) + ON CONFLICT(username) DO UPDATE SET customer_id = excluded.customer_id, owner = excluded.owner, + method = excluded.method, linked_at_ms = excluded.linked_at_ms, linked_by = excluded.linked_by, + unlinked_at_ms = NULL, unlinked_by = NULL`, + ) + .run(r.username, r.customerId, r.owner ?? null, r.method, r.linkedAtMs, r.linkedBy ?? null); + } + markUnlinked(username: string, atMs: number, by?: string) { + this.db + .prepare('UPDATE peergos_links SET unlinked_at_ms = ?, unlinked_by = ? WHERE username = ?') + .run(atMs, by ?? null, username); + } + get(username: string) { + const r = this.db.prepare('SELECT * FROM peergos_links WHERE username = ?').get(username) as + | Record + | undefined; + return r ? this.map(r) : undefined; + } + list() { + return (this.db.prepare('SELECT * FROM peergos_links ORDER BY linked_at_ms').all() as Record[]).map( + (r) => this.map(r), + ); + } +} + +/* ---------------- service ---------------- */ + +export class LinkError extends Error { + constructor( + public status: number, + public code: string, + message?: string, + ) { + super(message ?? code); + } +} + +export interface PeergosLinkDeps { + customers: CustomerRepo; + sessions: SessionRepo; + credits: CreditLedgerLike; + links: PeergosLinkRepo; + /** username → owner (peergos_owner_keys cache + Peergos getPublicKey). */ + resolveOwner: (username: string) => Promise; + identityKeys: IdentityKeyLookup; + quota?: PeergosQuotaService; + /** Called after any link change (gate index, quota cache). */ + onChange?: () => void; + now?: () => number; + sessionTtlMs?: number; +} + +export function normalizePeergosUsername(raw: unknown): string { + const u = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (!USERNAME_RE.test(u)) throw new LinkError(400, 'invalid_peergos_username'); + return u; +} + +export class PeergosLinkService { + readonly challenges: LinkChallengeStore; + private now: () => number; + constructor(private deps: PeergosLinkDeps) { + this.now = deps.now ?? Date.now; + this.challenges = new LinkChallengeStore(this.now); + } + + async issueChallenge(usernameRaw: unknown, purposeRaw: unknown): Promise { + const username = normalizePeergosUsername(usernameRaw); + const purpose: LinkPurpose = purposeRaw === 'link' ? 'link' : 'signup'; + const owner = await this.deps.resolveOwner(username).catch(() => { + throw new LinkError(502, 'peergos_unavailable'); + }); + if (!owner) throw new LinkError(404, 'peergos_user_not_found'); + this.assertLocalUser(username); + return this.challenges.issue(username, purpose); + } + + /** Verifies and consumes a signed challenge; returns the proven owner key hash. */ + async verifyProof(usernameRaw: unknown, challengeId: unknown, signatureHex: unknown, purpose: LinkPurpose) { + const username = normalizePeergosUsername(usernameRaw); + if (typeof challengeId !== 'string' || typeof signatureHex !== 'string' || !/^[0-9a-f]+$/i.test(signatureHex)) { + throw new LinkError(400, 'challenge_and_signature_required'); + } + const c = this.challenges.take(challengeId); + if (!c || c.username !== username || c.purpose !== purpose) throw new LinkError(401, 'challenge_invalid_or_expired'); + const owner = await this.deps.resolveOwner(username); + if (!owner) throw new LinkError(404, 'peergos_user_not_found'); + let key: Buffer; + try { + key = await this.deps.identityKeys(owner); + } catch { + throw new LinkError(502, 'peergos_key_unavailable'); + } + const msg = ed25519Open(key, Buffer.from(signatureHex, 'hex')); + if (!msg || msg.toString('utf8') !== c.message) throw new LinkError(401, 'peergos_signature_invalid'); + return { username, owner }; + } + + private async linkedParent(username: string): Promise { + const child = await this.deps.customers.findById(peergosCustomerId(username)); + return child?.parentId ? this.deps.customers.findById(child.parentId) : undefined; + } + + async assertLinkable(username: string, customerId?: string): Promise { + const parent = await this.linkedParent(username); + if (parent && parent.id !== customerId) throw new LinkError(409, 'peergos_user_linked_to_another_account'); + } + + /** + * Only accounts registered on our Peergos server can be linked: the quota hook's + * user table is Peergos's list of local users (a name that exists only on another + * Peergos server resolves and can sign, but must not become "local" here). + */ + assertLocalUser(username: string): void { + if (!this.deps.quota) return; + const u = this.deps.quota.store.getUser(username); + if (!u || u.removedAtMs !== undefined) throw new LinkError(404, 'peergos_user_not_on_this_server'); + } + + async link(customer: Customer, username: string, owner: string, method: string, by?: string) { + if (customer.parentId) throw new LinkError(400, 'customer_is_a_sub_account'); + this.assertLocalUser(username); + await this.assertLinkable(username, customer.id); + const child = await provisionPeergosUser(this.deps.customers, { + parentId: customer.id, + peergosUsername: username, + email: customer.email, + veraeUserId: customer.veraeUserId, + }); + this.deps.links.record({ username, customerId: customer.id, owner, method, linkedAtMs: this.now(), linkedBy: by }); + this.deps.onChange?.(); + return this.linkView(username, child.parentId!); + } + + async unlink(usernameRaw: unknown, by?: string) { + const username = normalizePeergosUsername(usernameRaw); + const child = await this.deps.customers.findById(peergosCustomerId(username)); + if (!child?.parentId) throw new LinkError(404, 'peergos_user_not_linked'); + const formerCustomerId = child.parentId; + await this.deps.customers.save({ ...child, parentId: undefined }); + this.deps.links.markUnlinked(username, this.now(), by); + this.deps.onChange?.(); + return { username, unlinked: true, formerCustomerId }; + } + + async linkView(username: string, customerId?: string) { + const parent = await this.linkedParent(username); + const rec = this.deps.links.get(username); + const q = this.deps.quota ? await this.deps.quota.decide(username) : undefined; + return { + username, + linked: !!parent && (!customerId || parent.id === customerId), + customerId: parent?.id ?? null, + tierId: parent?.tierId ?? null, + linkedAt: rec && !rec.unlinkedAtMs ? new Date(rec.linkedAtMs).toISOString() : null, + method: rec && !rec.unlinkedAtMs ? rec.method : null, + quotaBytes: q?.quotaBytes ?? null, + quotaBasis: q?.basis ?? null, + graceEndsAt: q?.graceEndsAtMs ? new Date(q.graceEndsAtMs).toISOString() : null, + }; + } + + async linksFor(customer: Customer) { + const children = (await this.deps.customers.listChildren(customer.id)).filter((c) => c.id.startsWith('peergos:')); + return Promise.all(children.map((c) => this.linkView(c.id.slice('peergos:'.length), customer.id))); + } +} + +/* ---------------- routers ---------------- */ + +function sendError(res: import('express').Response, e: unknown) { + if (e instanceof LinkError) { + res.status(e.status).json({ error: e.code }); + return; + } + res.status(500).json({ error: 'internal_error', message: (e as Error).message }); +} + +export function peergosLinkRouter(svc: PeergosLinkService, deps: PeergosLinkDeps): Router { + const router = Router(); + const now = deps.now ?? Date.now; + const ttl = deps.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS; + const challengePerIp = new RateLimiter(30, 15 * 60 * 1000, now); + const signupPerIp = new RateLimiter(10, 15 * 60 * 1000, now); + const statusPerIp = new RateLimiter(120, 15 * 60 * 1000, now); + + router.post('/challenge', async (req, res) => { + if (!challengePerIp.hit(clientIp(req))) { + res.status(429).json({ error: 'too_many_requests' }); + return; + } + try { + const c = await svc.issueChallenge(req.body?.peergosUsername, req.body?.purpose); + res.json({ + challengeId: c.id, + message: c.message, + peergosUsername: c.username, + purpose: c.purpose, + expiresAt: new Date(c.expiresAtMs).toISOString(), + }); + } catch (e) { + sendError(res, e); + } + }); + + router.post('/signup', async (req, res) => { + if (!signupPerIp.hit(clientIp(req))) { + res.status(429).json({ error: 'too_many_requests' }); + return; + } + const body = req.body ?? {}; + try { + const invalid = await validatePortalSignup(deps, body); + if (invalid) { + res.status(invalid.status).json({ error: invalid.error }); + return; + } + const { username, owner } = await svc.verifyProof(body.peergosUsername, body.challengeId, body.signature, 'signup'); + await svc.assertLinkable(username); + svc.assertLocalUser(username); + const r = await portalSignup(deps, body, deps.credits, ttl); + if ('error' in r) { + res.status(r.status).json({ error: r.error }); + return; + } + const link = await svc.link(r.customer, username, owner, 'app-signup', r.customer.id); + res.status(201).json({ token: r.token, customer: publicProfile(r.customer), link }); + } catch (e) { + sendError(res, e); + } + }); + + router.get('/link-status/:username', async (req, res) => { + if (!statusPerIp.hit(clientIp(req))) { + res.status(429).json({ error: 'too_many_requests' }); + return; + } + try { + const username = normalizePeergosUsername(req.params.username); + const child = await deps.customers.findById(peergosCustomerId(username)); + res.json({ username, linked: !!child?.parentId }); + } catch (e) { + sendError(res, e); + } + }); + + const auth = sessionAuth({ sessions: deps.sessions, customers: deps.customers, now: deps.now }); + + router.post('/link', auth, async (req, res) => { + try { + const { username, owner } = await svc.verifyProof( + req.body?.peergosUsername, + req.body?.challengeId, + req.body?.signature, + 'link', + ); + res.json(await svc.link(req.customer!, username, owner, 'app-link', req.customer!.id)); + } catch (e) { + sendError(res, e); + } + }); + + router.get('/links', auth, async (req, res) => { + try { + res.json({ customerId: req.customer!.id, links: await svc.linksFor(req.customer!) }); + } catch (e) { + sendError(res, e); + } + }); + + return router; +} + +export function peergosLinkStaffRouter(svc: PeergosLinkService, links: PeergosLinkRepo): Router { + const router = Router(); + router.get('/peergos/links', async (_req, res) => { + res.json({ + links: links.list().map((r) => ({ + ...r, + linkedAt: new Date(r.linkedAtMs).toISOString(), + unlinkedAt: r.unlinkedAtMs ? new Date(r.unlinkedAtMs).toISOString() : null, + })), + }); + }); + router.get('/peergos/links/:username', async (req, res) => { + try { + res.json({ ...(await svc.linkView(normalizePeergosUsername(req.params.username))), record: links.get(req.params.username) ?? null }); + } catch (e) { + sendError(res, e); + } + }); + router.delete('/peergos/links/:username', async (req, res) => { + try { + res.json(await svc.unlink(req.params.username, req.staff?.username ?? 'admin')); + } catch (e) { + sendError(res, e); + } + }); + return router; +} diff --git a/src/peergos-quota.ts b/src/peergos-quota.ts new file mode 100644 index 0000000..954a2ff --- /dev/null +++ b/src/peergos-quota.ts @@ -0,0 +1,698 @@ +import type Database from 'better-sqlite3'; +import { Request, Response, Router } from 'express'; +import { Customer, CustomerRepo } from './auth'; +import { TierConfig } from './pricing'; +import { + Cbor, + cbor, + cborDecode, + cborEncode, + ed25519Open, + IdentityKeyLookup, + verifySignedTime, +} from './peergos-identity'; + +/** + * Peergos quotas come from the billing service (George, 2026-10-06). + * + * Peergos runs with `-quota-admin-address`, so instead of its local quotas.sql it + * asks this service over HTTP (`HttpQuotaAdmin`, CBOR replies) for every quota, + * the list of local users, signup permission and signup tokens. The routes under + * /quota-admin/ mirror upstream `HttpQuotaAdmin` and web-ui's QuotaAdminStandIn. + * + * Quota for a Peergos user (bytes, GiB = 2^30): + * - removed (quota-remove) → 0 + * - linked to a Verae customer (peergos: child) → the customer's tier + * included storage (Free 1, Pro 50, Business 500 GiB; customer override wins) + * - internal/test account, not linked (peergos, verae-admin, verae-ops, grokbot*) + * → PEERGOS_INTERNAL_QUOTA_BYTES (default 5 GiB) + * - any other known user, not linked → PEERGOS_UNLINKED_QUOTA_BYTES + * (default 1 GiB). The link grace period (PEERGOS_LINK_GRACE_DAYS, default 30) + * is reported to the app; it does not change the quota yet (design N5). + * - unknown user → 0 (Peergos treats them as not local) + * and is never below the user's imported legacy quota (accounts that existed before + * the switch keep at least what they had) or an admin override (auto-raise hook). + */ + +const GIB = 1024 * 1024 * 1024; + +export interface QuotaPolicy { + internalUsers: string[]; + internalPrefixes: string[]; + internalQuotaBytes: number; + unlinkedQuotaBytes: number; + graceDays: number; + maxUsers: number; +} + +export function quotaPolicyFromEnv(env: NodeJS.ProcessEnv = process.env): QuotaPolicy { + const list = (v: string | undefined, d: string) => + (v ?? d).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); + const num = (v: string | undefined, d: number) => { + const n = Number(v); + return v !== undefined && v.trim() !== '' && Number.isFinite(n) && n >= 0 ? Math.trunc(n) : d; + }; + return { + internalUsers: list(env.PEERGOS_INTERNAL_USERS, 'peergos,verae-admin,verae-ops'), + internalPrefixes: list(env.PEERGOS_INTERNAL_PREFIXES, 'grokbot'), + internalQuotaBytes: num(env.PEERGOS_INTERNAL_QUOTA_BYTES, 5 * GIB), + unlinkedQuotaBytes: num(env.PEERGOS_UNLINKED_QUOTA_BYTES, 1 * GIB), + graceDays: num(env.PEERGOS_LINK_GRACE_DAYS, 30), + maxUsers: num(env.PEERGOS_MAX_USERS, 100_000), + }; +} + +/* ---------------- store (additive tables) ---------------- */ + +export interface QuotaUser { + username: string; + legacyQuotaBytes?: number; + source: string; + firstSeenMs: number; + removedAtMs?: number; +} + +export interface QuotaOverride { + username: string; + quotaBytes: number; + reason?: string; + setAtMs: number; + setBy?: string; +} + +export interface PeergosQuotaStore { + getUser(username: string): QuotaUser | undefined; + listUsers(includeRemoved?: boolean): QuotaUser[]; + /** Inserts if absent; returns true when the user is new. */ + addUser(u: QuotaUser): boolean; + setLegacyQuota(username: string, bytes: number): void; + markRemoved(username: string, atMs: number): void; + countActive(): number; + addToken(token: string, atMs: number): boolean; + /** Consumes an unused token for `username`; true if it was valid. */ + consumeToken(token: string, username: string, atMs: number): boolean; + hasToken(token: string): boolean; + deleteToken(token: string, atMs: number): boolean; + listTokens(): string[]; + getOverride(username: string): QuotaOverride | undefined; + setOverride(o: QuotaOverride): void; + clearOverride(username: string): boolean; + /** Reverse of peergos_owner_keys. */ + usernameForOwner(owner: string): string | undefined; +} + +export const PEERGOS_QUOTA_DDL = ` +CREATE TABLE IF NOT EXISTS peergos_quota_users ( + username TEXT PRIMARY KEY, + legacy_quota_bytes INTEGER, + source TEXT NOT NULL, + first_seen_ms INTEGER NOT NULL, + removed_at_ms INTEGER +); +CREATE TABLE IF NOT EXISTS peergos_signup_tokens ( + token TEXT PRIMARY KEY, + created_at_ms INTEGER NOT NULL, + consumed_by TEXT, + consumed_at_ms INTEGER, + deleted_at_ms INTEGER +); +CREATE TABLE IF NOT EXISTS peergos_quota_overrides ( + username TEXT PRIMARY KEY, + quota_bytes INTEGER NOT NULL, + reason TEXT, + set_at_ms INTEGER NOT NULL, + set_by TEXT +); +CREATE TABLE IF NOT EXISTS peergos_owner_keys ( + username TEXT PRIMARY KEY, + owner TEXT NOT NULL, + resolved_at_ms INTEGER NOT NULL +); +`; + +export class InMemoryPeergosQuotaStore implements PeergosQuotaStore { + users = new Map(); + tokens = new Map(); + overrides = new Map(); + owners = new Map(); // owner -> username + getUser(u: string) { + const x = this.users.get(u); + return x ? { ...x } : undefined; + } + listUsers(includeRemoved = false) { + return [...this.users.values()].filter((u) => includeRemoved || u.removedAtMs === undefined).map((u) => ({ ...u })); + } + addUser(u: QuotaUser) { + if (this.users.has(u.username)) return false; + this.users.set(u.username, { ...u }); + return true; + } + setLegacyQuota(username: string, bytes: number) { + const u = this.users.get(username); + if (u) u.legacyQuotaBytes = bytes; + } + markRemoved(username: string, atMs: number) { + const u = this.users.get(username); + if (u) u.removedAtMs = atMs; + } + countActive() { + return this.listUsers().length; + } + addToken(token: string) { + if (this.tokens.has(token)) return false; + this.tokens.set(token, {}); + return true; + } + consumeToken(token: string, username: string) { + const t = this.tokens.get(token); + if (!t || t.consumedBy || t.deleted) return false; + t.consumedBy = username; + return true; + } + hasToken(token: string) { + const t = this.tokens.get(token); + return !!t && !t.consumedBy && !t.deleted; + } + deleteToken(token: string) { + const t = this.tokens.get(token); + if (!t || t.consumedBy || t.deleted) return false; + t.deleted = true; + return true; + } + listTokens() { + return [...this.tokens.entries()].filter(([, t]) => !t.consumedBy && !t.deleted).map(([k]) => k); + } + getOverride(u: string) { + return this.overrides.get(u); + } + setOverride(o: QuotaOverride) { + this.overrides.set(o.username, { ...o }); + } + clearOverride(u: string) { + return this.overrides.delete(u); + } + usernameForOwner(owner: string) { + return this.owners.get(owner); + } +} + +export class SqlitePeergosQuotaStore implements PeergosQuotaStore { + constructor(private db: Database.Database) { + db.exec(PEERGOS_QUOTA_DDL); + } + private row(r: Record | undefined): QuotaUser | undefined { + if (!r) return undefined; + return { + username: r.username as string, + legacyQuotaBytes: (r.legacy_quota_bytes as number | null) ?? undefined, + source: r.source as string, + firstSeenMs: r.first_seen_ms as number, + removedAtMs: (r.removed_at_ms as number | null) ?? undefined, + }; + } + getUser(username: string) { + return this.row(this.db.prepare('SELECT * FROM peergos_quota_users WHERE username = ?').get(username) as Record | undefined); + } + listUsers(includeRemoved = false) { + const rows = this.db + .prepare(`SELECT * FROM peergos_quota_users ${includeRemoved ? '' : 'WHERE removed_at_ms IS NULL'} ORDER BY username`) + .all() as Record[]; + return rows.map((r) => this.row(r)!); + } + addUser(u: QuotaUser) { + const r = this.db + .prepare( + `INSERT INTO peergos_quota_users (username, legacy_quota_bytes, source, first_seen_ms, removed_at_ms) + VALUES (?, ?, ?, ?, NULL) ON CONFLICT(username) DO NOTHING`, + ) + .run(u.username, u.legacyQuotaBytes ?? null, u.source, u.firstSeenMs); + return r.changes > 0; + } + setLegacyQuota(username: string, bytes: number) { + this.db.prepare('UPDATE peergos_quota_users SET legacy_quota_bytes = ? WHERE username = ?').run(bytes, username); + } + markRemoved(username: string, atMs: number) { + this.db.prepare('UPDATE peergos_quota_users SET removed_at_ms = ? WHERE username = ?').run(atMs, username); + } + countActive() { + return (this.db.prepare('SELECT COUNT(*) AS n FROM peergos_quota_users WHERE removed_at_ms IS NULL').get() as { n: number }).n; + } + addToken(token: string, atMs: number) { + return ( + this.db + .prepare('INSERT INTO peergos_signup_tokens (token, created_at_ms) VALUES (?, ?) ON CONFLICT(token) DO NOTHING') + .run(token, atMs).changes > 0 + ); + } + consumeToken(token: string, username: string, atMs: number) { + return ( + this.db + .prepare( + `UPDATE peergos_signup_tokens SET consumed_by = ?, consumed_at_ms = ? + WHERE token = ? AND consumed_by IS NULL AND deleted_at_ms IS NULL`, + ) + .run(username, atMs, token).changes > 0 + ); + } + hasToken(token: string) { + return !!this.db + .prepare('SELECT 1 FROM peergos_signup_tokens WHERE token = ? AND consumed_by IS NULL AND deleted_at_ms IS NULL') + .get(token); + } + deleteToken(token: string, atMs: number) { + return ( + this.db + .prepare( + `UPDATE peergos_signup_tokens SET deleted_at_ms = ? + WHERE token = ? AND consumed_by IS NULL AND deleted_at_ms IS NULL`, + ) + .run(atMs, token).changes > 0 + ); + } + listTokens() { + return ( + this.db + .prepare('SELECT token FROM peergos_signup_tokens WHERE consumed_by IS NULL AND deleted_at_ms IS NULL ORDER BY created_at_ms') + .all() as { token: string }[] + ).map((r) => r.token); + } + getOverride(username: string) { + const r = this.db.prepare('SELECT * FROM peergos_quota_overrides WHERE username = ?').get(username) as + | Record + | undefined; + return r + ? { + username, + quotaBytes: r.quota_bytes as number, + reason: (r.reason as string | null) ?? undefined, + setAtMs: r.set_at_ms as number, + setBy: (r.set_by as string | null) ?? undefined, + } + : undefined; + } + setOverride(o: QuotaOverride) { + this.db + .prepare( + `INSERT INTO peergos_quota_overrides (username, quota_bytes, reason, set_at_ms, set_by) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(username) DO UPDATE SET quota_bytes = excluded.quota_bytes, reason = excluded.reason, + set_at_ms = excluded.set_at_ms, set_by = excluded.set_by`, + ) + .run(o.username, o.quotaBytes, o.reason ?? null, o.setAtMs, o.setBy ?? null); + } + clearOverride(username: string) { + return this.db.prepare('DELETE FROM peergos_quota_overrides WHERE username = ?').run(username).changes > 0; + } + usernameForOwner(owner: string) { + const r = this.db.prepare('SELECT username FROM peergos_owner_keys WHERE owner = ?').get(owner) as + | { username: string } + | undefined; + return r?.username; + } +} + +/* ---------------- quota service ---------------- */ + +export interface QuotaDecision { + username: string; + quotaBytes: number; + basis: 'removed' | 'unknown' | 'linked' | 'internal' | 'unlinked'; + customerId?: string; + tierId?: string; + planBytes?: number; + legacyQuotaBytes?: number; + overrideBytes?: number; + graceEndsAtMs?: number; +} + +export interface PeergosQuotaDeps { + store: PeergosQuotaStore; + customers: CustomerRepo; + tiers: () => TierConfig[]; + policy?: QuotaPolicy; + /** Resolves username → owner (and records it in peergos_owner_keys). */ + resolveOwner?: (username: string) => Promise; + identityKeys?: IdentityKeyLookup; + now?: () => number; + log?: (m: string) => void; +} + +const CACHE_TTL_MS = 15_000; + +export class PeergosQuotaService { + readonly policy: QuotaPolicy; + private cache = new Map(); + private childIndex: { at: number; byUser: Map } | undefined; + private now: () => number; + + constructor(private deps: PeergosQuotaDeps) { + this.policy = deps.policy ?? quotaPolicyFromEnv(); + this.now = deps.now ?? Date.now; + } + + invalidate(): void { + this.cache.clear(); + this.childIndex = undefined; + } + + get store(): PeergosQuotaStore { + return this.deps.store; + } + + isInternal(username: string): boolean { + const u = username.toLowerCase(); + return this.policy.internalUsers.includes(u) || this.policy.internalPrefixes.some((p) => u.startsWith(p)); + } + + private async children() { + const t = this.now(); + if (this.childIndex && t - this.childIndex.at < CACHE_TTL_MS) return this.childIndex.byUser; + const all = await this.deps.customers.list(); + const byId = new Map(all.map((c) => [c.id, c])); + const byUser = new Map(); + for (const c of all) { + if (!c.id.startsWith('peergos:') || !c.parentId) continue; + byUser.set(c.id.slice('peergos:'.length), { child: c, parent: byId.get(c.parentId) }); + } + this.childIndex = { at: t, byUser }; + return byUser; + } + + async decide(usernameRaw: string): Promise { + const username = usernameRaw.trim().toLowerCase(); + const t = this.now(); + const hit = this.cache.get(username); + if (hit && t - hit.at < CACHE_TTL_MS) return hit.d; + const d = await this.compute(username); + this.cache.set(username, { at: t, d }); + return d; + } + + private async compute(username: string): Promise { + const user = this.deps.store.getUser(username); + if (user?.removedAtMs !== undefined) return { username, quotaBytes: 0, basis: 'removed' }; + const link = (await this.children()).get(username); + const override = this.deps.store.getOverride(username)?.quotaBytes; + const legacy = user?.legacyQuotaBytes; + const floor = Math.max(legacy ?? 0, override ?? 0); + const base = { username, legacyQuotaBytes: legacy, overrideBytes: override }; + if (link?.parent) { + const parent = link.parent; + const tier = this.deps.tiers().find((x) => x.id === parent.tierId); + const gb = parent.includedStorageGbOverride ?? tier?.includedStorageGb ?? 1; + const planBytes = Math.round(gb * GIB); + return { + ...base, + quotaBytes: Math.max(planBytes, floor), + basis: 'linked', + customerId: parent.id, + tierId: parent.tierId, + planBytes, + }; + } + if (!user) return { username, quotaBytes: 0, basis: 'unknown' }; + if (this.isInternal(username)) { + return { ...base, quotaBytes: Math.max(this.policy.internalQuotaBytes, floor), basis: 'internal' }; + } + return { + ...base, + quotaBytes: Math.max(this.policy.unlinkedQuotaBytes, floor), + basis: 'unlinked', + graceEndsAtMs: user.firstSeenMs + this.policy.graceDays * 86_400_000, + }; + } + + async quotaBytes(username: string): Promise { + return (await this.decide(username)).quotaBytes; + } + + async knownUsernames(): Promise { + const names = new Set(this.deps.store.listUsers().map((u) => u.username)); + for (const u of (await this.children()).keys()) { + if (this.deps.store.getUser(u)?.removedAtMs === undefined) names.add(u); + } + return [...names].sort(); + } + + /** Peergos allowSignupOrUpdate (UserQuotas semantics, with our user table). */ + async allowSignupOrUpdate(usernameRaw: string, token: string): Promise { + const username = usernameRaw.trim().toLowerCase(); + const u = this.deps.store.getUser(username); + if (u && u.removedAtMs === undefined) return true; + if ((await this.children()).has(username)) { + this.deps.store.addUser({ username, source: 'linked', firstSeenMs: this.now() }); + return true; + } + if (token && this.deps.store.hasToken(token)) return true; + if (u) return false; // removed accounts don't come back without a token + if (this.deps.store.countActive() >= this.policy.maxUsers) return false; + this.deps.store.addUser({ username, source: 'signup', firstSeenMs: this.now() }); + this.cache.delete(username); + return true; + } + + async usernameForOwner(owner: string): Promise { + const known = this.deps.store.usernameForOwner(owner); + if (known) return known; + // New accounts: resolve owners for users we have not mapped yet. + if (!this.deps.resolveOwner) return undefined; + for (const name of await this.knownUsernames()) { + try { + if ((await this.deps.resolveOwner(name)) === owner) return name; + } catch (e) { + this.deps.log?.(`peergos-quota: owner lookup for ${name} failed: ${(e as Error).message}`); + } + } + return undefined; + } + + async verifyOwnerTime(owner: string, authHex: string): Promise { + if (!this.deps.identityKeys) throw new Error('identity key lookup not configured'); + if (!/^[0-9a-f]+$/i.test(authHex)) throw new Error('auth must be hex'); + await verifySignedTime(this.deps.identityKeys, owner, Buffer.from(authHex, 'hex'), this.now()); + } + + /** QuotaAdmin.parseQuotaRequest: verify a signed SpaceRequest {u,s,a,t,p}. */ + async verifySpaceRequest(owner: string, reqHex: string): Promise<{ username: string; bytes: number }> { + if (!this.deps.identityKeys) throw new Error('identity key lookup not configured'); + if (!/^[0-9a-f]+$/i.test(reqHex)) throw new Error('req must be hex'); + const msg = ed25519Open(await this.deps.identityKeys(owner), Buffer.from(reqHex, 'hex')); + if (!msg) throw new Error('bad signature on space request'); + const c = cborDecode(msg); + if (c.t !== 'map') throw new Error('invalid space request'); + const u = c.v.get('u'); + const s = c.v.get('s'); + const tt = c.v.get('t'); + if (u?.t !== 'text' || s?.t !== 'int' || tt?.t !== 'int') throw new Error('invalid space request'); + if (Number(tt.v) < this.now() - 300_000) throw new Error('Stale auth time in space request!'); + return { username: u.v, bytes: Number(s.v) }; + } +} + +/* ---------------- /quota-admin/* (Peergos HttpQuotaAdmin) ---------------- */ + +export function paymentPropertiesCbor(p: { freeQuota: number; desiredQuota?: number; error?: string }): Cbor { + const m: Record = { + freeQuota: cbor.int(p.freeQuota), + desiredQuota: cbor.int(p.desiredQuota ?? 0), + annual: cbor.bool(false), + nextCharge: cbor.int(0), + }; + if (p.error) m.err = cbor.text(p.error); + return cbor.map(m); +} + +export const SPACE_REQUEST_MESSAGE = + 'Storage is managed by your Verae account: open Settings > Usage & Billing in the Verae Peergos app.'; + +/** + * Router for the Peergos quota hook. Only the dedicated Caddy listener on ns1 + * (70.88.205.138:3041, reachable from ns2 only) forwards here; it sets + * X-Verae-Quota-Relay. Anything else gets 404, so the public zap.verae.ai site + * never exposes these calls. + */ +export function peergosQuotaAdminRouter(svc: PeergosQuotaService, opts: { requireRelayHeader?: boolean } = {}): Router { + const router = Router(); + const requireRelay = opts.requireRelayHeader ?? true; + + const reply = (res: Response, c: Cbor) => { + res.status(200).type('application/cbor').send(cborEncode(c)); + }; + const fail = (res: Response, msg: string) => { + // HttpQuotaAdmin surfaces the Trailer header as the error message. + res.setHeader('Trailer', encodeURIComponent(msg)); + res.status(400).end(); + }; + const q = (req: Request, k: string) => (typeof req.query[k] === 'string' ? (req.query[k] as string) : ''); + + router.use((req, res, next) => { + if (requireRelay && req.header('x-verae-quota-relay') !== '1') { + res.status(404).end(); + return; + } + next(); + }); + + router.all('/:call', async (req, res) => { + const store = svc.store; + const now = Date.now(); + try { + switch (req.params.call) { + case 'signups': + return reply(res, cbor.map({ f: cbor.bool(store.countActive() < svc.policy.maxUsers), p: cbor.bool(false) })); + case 'allowed': + return reply(res, cbor.bool(await svc.allowSignupOrUpdate(q(req, 'username'), q(req, 'token')))); + case 'create-paid': + return fail(res, 'Paid signups are not offered on this server'); + case 'remove-desired-quota': + return reply(res, cbor.bool(true)); + case 'token-add': + return reply(res, cbor.bool(!!q(req, 'token') && store.addToken(q(req, 'token'), now))); + case 'token-remove': { + const username = q(req, 'username').toLowerCase(); + const ok = !!q(req, 'token') && store.consumeToken(q(req, 'token'), username, now); + if (ok) { + store.addUser({ username, source: 'token', firstSeenMs: now }); + svc.invalidate(); + } + return reply(res, cbor.bool(ok)); + } + case 'token-list': + return reply(res, cbor.list(store.listTokens().map(cbor.text))); + case 'token-delete': + return reply(res, cbor.bool(!!q(req, 'token') && store.deleteToken(q(req, 'token'), now))); + case 'quota-by-name': + return reply(res, cbor.int(await svc.quotaBytes(q(req, 'username')))); + case 'quota-remove': + store.markRemoved(q(req, 'username').toLowerCase(), now); + svc.invalidate(); + return reply(res, cbor.bool(true)); + case 'quota-by-name-time': { + const u = store.getUser(q(req, 'username').toLowerCase()); + const at = Date.parse(q(req, 'time') + 'Z'); + const had = !!u && Number.isFinite(at) && u.firstSeenMs <= at && (u.removedAtMs === undefined || u.removedAtMs > at); + return reply(res, cbor.bool(had)); + } + case 'usernames': + return reply(res, cbor.list((await svc.knownUsernames()).map(cbor.text))); + case 'quota': { + const owner = q(req, 'owner'); + await svc.verifyOwnerTime(owner, q(req, 'auth')); + const username = await svc.usernameForOwner(owner); + if (!username) return fail(res, 'Unknown owner'); + return reply(res, cbor.int(await svc.quotaBytes(username))); + } + case 'payment-properties': { + const owner = q(req, 'owner'); + await svc.verifyOwnerTime(owner, q(req, 'auth')); + const username = await svc.usernameForOwner(owner); + if (!username) return fail(res, 'Unknown owner'); + return reply(res, paymentPropertiesCbor({ freeQuota: await svc.quotaBytes(username) })); + } + case 'request': { + const owner = q(req, 'owner'); + const r = await svc.verifySpaceRequest(owner, q(req, 'req')); + const username = await svc.usernameForOwner(owner); + if (!username || username !== r.username.toLowerCase()) return fail(res, 'Space request does not match owner'); + return reply( + res, + paymentPropertiesCbor({ freeQuota: await svc.quotaBytes(username), error: SPACE_REQUEST_MESSAGE }), + ); + } + default: + return fail(res, `Unknown quota admin call ${req.params.call}`); + } + } catch (e) { + return fail(res, (e as Error).message); + } + }); + + return router; +} + +/* ---------------- staff admin routes (/admin/api/peergos/quotas) ---------------- */ + +const USERNAME_RE = /^[a-z0-9][a-z0-9_.-]{0,31}$/; + +export function peergosQuotaStaffRouter(svc: PeergosQuotaService): Router { + const router = Router(); + const view = async (username: string) => { + const d = await svc.decide(username); + return { + ...d, + graceEndsAt: d.graceEndsAtMs ? new Date(d.graceEndsAtMs).toISOString() : undefined, + quotaGiB: Math.round((d.quotaBytes / GIB) * 1000) / 1000, + }; + }; + + router.get('/peergos/quotas', async (_req, res) => { + const names = new Set(await svc.knownUsernames()); + for (const u of svc.store.listUsers(true)) names.add(u.username); + res.json({ policy: svc.policy, users: await Promise.all([...names].sort().map(view)) }); + }); + + router.get('/peergos/quotas/:username', async (req, res) => { + res.json(await view(req.params.username.toLowerCase())); + }); + + /** One-time import of the users and unused signup tokens from Peergos quotas.sql. */ + router.post('/peergos/quotas/import', (req, res) => { + const { users, tokens } = req.body ?? {}; + if (!Array.isArray(users)) { + res.status(400).json({ error: 'users[] required' }); + return; + } + const now = Date.now(); + let added = 0; + let updated = 0; + for (const u of users) { + const username = typeof u?.username === 'string' ? u.username.trim().toLowerCase() : ''; + const bytes = Number(u?.quotaBytes); + if (!USERNAME_RE.test(username) || !Number.isFinite(bytes) || bytes < 0) { + res.status(400).json({ error: `bad user entry ${JSON.stringify(username)}` }); + return; + } + if (svc.store.addUser({ username, legacyQuotaBytes: bytes, source: 'import', firstSeenMs: now })) added++; + else { + const cur = svc.store.getUser(username); + if ((cur?.legacyQuotaBytes ?? 0) < bytes) { + svc.store.setLegacyQuota(username, bytes); + updated++; + } + } + } + let tokensAdded = 0; + if (Array.isArray(tokens)) { + for (const t of tokens) if (typeof t === 'string' && t && svc.store.addToken(t, now)) tokensAdded++; + } + svc.invalidate(); + res.json({ added, updated, tokensAdded, users: svc.store.countActive() }); + }); + + /** Admin override / auto-raise hook: quota never goes below this. */ + router.put('/peergos/quotas/:username/override', async (req, res) => { + const username = req.params.username.toLowerCase(); + const bytes = Number(req.body?.quotaBytes); + if (!USERNAME_RE.test(username) || !Number.isFinite(bytes) || bytes < 0) { + res.status(400).json({ error: 'quotaBytes (>= 0) required' }); + return; + } + svc.store.setOverride({ + username, + quotaBytes: Math.trunc(bytes), + reason: typeof req.body?.reason === 'string' ? req.body.reason : undefined, + setAtMs: Date.now(), + setBy: req.staff?.username ?? 'admin', + }); + svc.invalidate(); + res.json(await view(username)); + }); + + router.delete('/peergos/quotas/:username/override', async (req, res) => { + const username = req.params.username.toLowerCase(); + const removed = svc.store.clearOverride(username); + svc.invalidate(); + res.json({ removed, ...(await view(username)) }); + }); + + return router; +} diff --git a/src/portal.ts b/src/portal.ts index c9349a5..2c4c0ff 100644 --- a/src/portal.ts +++ b/src/portal.ts @@ -66,7 +66,7 @@ export interface PortalDeps { billing?: Omit; } -const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +export const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; const MIN_RELOAD_CENTS = 100; // $1 const MAX_RELOAD_CENTS = 1_000_000; // $10,000 const MIN_PASSWORD_LENGTH = 8; @@ -90,7 +90,7 @@ function passwordPolicyError(password: unknown): string | undefined { } /** Client IP: Caddy appends the real peer as the last X-Forwarded-For entry. */ -function clientIp(req: import('express').Request): string { +export function clientIp(req: import('express').Request): string { const xff = req.header('x-forwarded-for'); if (xff) { const parts = xff.split(',').map((p) => p.trim()).filter(Boolean); @@ -115,7 +115,7 @@ function resetEmail(code: string): { subject: string; text: string; html: string } /** Profile payload safe to return to the customer (no hashes/secrets). */ -function publicProfile(c: Customer) { +export function publicProfile(c: Customer) { return { id: c.id, name: c.name, @@ -130,7 +130,7 @@ function publicProfile(c: Customer) { }; } -function sessionAuth(deps: PortalDeps): RequestHandler { +export function sessionAuth(deps: Pick): RequestHandler { return (req, res, next) => { void (async () => { const bearer = req.header('authorization'); @@ -152,6 +152,73 @@ async function save(deps: PortalDeps, customer: Customer): Promise { await deps.customers.save(customer); } +/** Field and duplicate-email checks for portalSignup; undefined = OK. */ +export async function validatePortalSignup( + deps: Pick, + body: { name?: unknown; email?: unknown; password?: unknown }, +): Promise<{ status: number; error: string } | undefined> { + const { name, email, password } = body; + if (typeof name !== 'string' || name.trim().length === 0) return { status: 400, error: 'name is required' }; + if (typeof email !== 'string' || !EMAIL_RE.test(email)) return { status: 400, error: 'valid email is required' }; + const pwErr = passwordPolicyError(password); + if (pwErr) return { status: 400, error: pwErr }; + const existing = await deps.customers.findByEmail(email); + if (existing?.passwordHash) return { status: 409, error: 'an account with this email already exists' }; + return undefined; +} + +export type PortalSignupResult = + | { customer: Customer; token: string; created: boolean } + | { status: number; error: string }; + +/** + * Create a portal customer (Free tier, no card) or claim an admin-created one, + * and open a session. Shared by POST /portal/api/signup and the Peergos app's + * POST /portal/api/peergos/signup. Callers validate anything extra first. + */ +export async function portalSignup( + deps: Pick, + body: { name?: unknown; email?: unknown; password?: unknown }, + credits: CreditLedgerLike, + ttlMs: number, +): Promise { + const invalid = await validatePortalSignup(deps, body); + if (invalid) return invalid; + const name = body.name as string; + const email = body.email as string; + const password = body.password as string; + const existing = await deps.customers.findByEmail(email); + const passwordHash = hashPassword(password); + let customer: Customer; + if (existing) { + // Claim flow: an admin-created customer sets their portal password once. + customer = { ...existing, name: existing.name || name.trim(), email, passwordHash }; + } else { + customer = { + id: `cust_${randomBytes(4).toString('hex')}`, + name: name.trim(), + email, + tierId: 'free', + apiKey: `key-${randomBytes(8).toString('hex')}`, + billingType: 'stripe', + passwordHash, + balanceCents: 0, + }; + } + if (!customer.veraeUserId) { + const bind = await bindVeraeUser(email); + customer = { ...customer, veraeUserId: bind.veraeUserId, veraeUsername: bind.veraeUsername }; + } + // $50 new-customer credit for brand-new portal accounts only. Claiming an + // admin-created account does not re-grant (staff create already did). + if (!existing) { + customer = (await grantSignupCredit({ customer, credits, plane: 'web', agent: 'portal-signup' })).customer; + } + await deps.customers.save(customer); + const session = deps.sessions.create(customer.id, ttlMs); + return { customer, token: session.token, created: !existing }; +} + export function portalRouter(deps: PortalDeps): Router { const router = Router(); const ttl = deps.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS; @@ -161,56 +228,12 @@ export function portalRouter(deps: PortalDeps): Router { /* ---------------- auth ---------------- */ router.post('/signup', async (req, res) => { - const { name, email, password } = req.body ?? {}; - if (typeof name !== 'string' || name.trim().length === 0) { - res.status(400).json({ error: 'name is required' }); + const r = await portalSignup(deps, req.body ?? {}, credits, ttl); + if ('error' in r) { + res.status(r.status).json({ error: r.error }); return; } - if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { - res.status(400).json({ error: 'valid email is required' }); - return; - } - const pwErr = passwordPolicyError(password); - if (pwErr) { - res.status(400).json({ error: pwErr }); - return; - } - const passwordHash = hashPassword(password); - const existing = await deps.customers.findByEmail(email); - let customer: Customer; - if (existing) { - if (existing.passwordHash) { - res.status(409).json({ error: 'an account with this email already exists' }); - return; - } - // Claim flow: an admin-created customer sets their portal password once. - customer = { ...existing, name: existing.name || name.trim(), email, passwordHash }; - } else { - customer = { - id: `cust_${randomBytes(4).toString('hex')}`, - name: name.trim(), - email, - tierId: 'free', - apiKey: `key-${randomBytes(8).toString('hex')}`, - billingType: 'stripe', - passwordHash, - balanceCents: 0, - }; - } - if (!customer.veraeUserId) { - const bind = await bindVeraeUser(email); - customer = { ...customer, veraeUserId: bind.veraeUserId, veraeUsername: bind.veraeUsername }; - } - // $50 new-customer credit for brand-new portal accounts only. Claiming an - // admin-created account does not re-grant (staff create already did). - if (!existing) { - customer = ( - await grantSignupCredit({ customer, credits, plane: 'web', agent: 'portal-signup' }) - ).customer; - } - await save(deps, customer); - const session = deps.sessions.create(customer.id, ttl); - res.status(201).json({ token: session.token, customer: publicProfile(customer) }); + res.status(201).json({ token: r.token, customer: publicProfile(r.customer) }); }); router.post('/login', async (req, res) => { diff --git a/src/staff-iam.ts b/src/staff-iam.ts index 71cf921..d348e8a 100644 --- a/src/staff-iam.ts +++ b/src/staff-iam.ts @@ -26,6 +26,9 @@ export function permForAdminPath(method: string, p: string): string | undefined if (p.startsWith('/invoices') || p.startsWith('/exports')) return 'admin.invoices'; if (p.startsWith('/reports')) return 'admin.reports'; if (p.startsWith('/zapier')) return 'admin.system'; + // Peergos links/quotas: unlink, overrides and imports change customer access. + if (p.startsWith('/peergos') && method !== 'GET') return 'admin.customers'; + if (p.startsWith('/customer-flags')) return 'admin.customers'; if (p.startsWith('/users') || p.startsWith('/staff')) { return method === 'GET' ? 'iam.users.read' : 'iam.users.write'; } diff --git a/tests/customer-flags.test.ts b/tests/customer-flags.test.ts new file mode 100644 index 0000000..11779a2 --- /dev/null +++ b/tests/customer-flags.test.ts @@ -0,0 +1,30 @@ +import request from 'supertest'; +import { buildApp } from '../src/app'; +import { UsageEntry } from '../src/usage'; + +const ADMIN = { 'x-admin-key': process.env.ADMIN_KEY ?? 'admin-dev-key' }; +const entry = (customerId: string, iso: string): UsageEntry => ({ + customerId, endpointId: 'transform', cents: 3, metadataBytes: 0, attachmentBytes: 0, timestamp: new Date(iso), +}); + +describe('customer flags', () => { + it('flags a test customer and skips it in invoice generation', async () => { + const built = buildApp(); + built.usage.record(entry('cust_2', '2026-07-05T10:00:00Z')); + built.usage.record(entry('cust_3', '2026-07-05T10:00:00Z')); + expect((await request(built.app).get('/admin/api/customers/cust_2/flags').set(ADMIN)).body).toMatchObject({ test: false, noInvoice: false }); + const put = await request(built.app) + .put('/admin/api/customers/cust_2/flags') + .set(ADMIN) + .send({ test: true, noInvoice: true, note: 'e2e' }) + .expect(200); + expect(put.body).toMatchObject({ customerId: 'cust_2', test: true, noInvoice: true, note: 'e2e' }); + await request(built.app).put('/admin/api/customers/cust_2/flags').set(ADMIN).send({ test: 'yes' }).expect(400); + await request(built.app).put('/admin/api/customers/nope/flags').set(ADMIN).send({ test: true }).expect(404); + const gen = await request(built.app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }).expect(200); + expect(gen.body.skipped).toContainEqual({ customerId: 'cust_2', reason: 'flagged no_invoice' }); + expect(gen.body.generated).toHaveLength(1); + const list = await request(built.app).get('/admin/api/customer-flags').set(ADMIN).expect(200); + expect(list.body.flags).toHaveLength(1); + }); +}); diff --git a/tests/helpers/cbor-client.ts b/tests/helpers/cbor-client.ts new file mode 100644 index 0000000..03707a9 --- /dev/null +++ b/tests/helpers/cbor-client.ts @@ -0,0 +1,26 @@ +import { Cbor, cborDecode } from '../../src/peergos-identity'; + +export function toJs(c: Cbor): unknown { + switch (c.t) { + case 'int': + return Number(c.v); + case 'list': + return c.v.map(toJs); + case 'map': + return Object.fromEntries([...c.v.entries()].map(([k, v]) => [k, toJs(v)])); + case 'bytes': + return Buffer.from(c.v).toString('hex'); + default: + return (c as { v: unknown }).v ?? null; + } +} + +/** supertest parser that keeps the raw body. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const binary = (res: any, cb: (err: Error | null, body: any) => void) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => cb(null, Buffer.concat(chunks))); +}; + +export const decode = (b: Buffer) => toJs(cborDecode(b)); diff --git a/tests/helpers/peergos-keys.ts b/tests/helpers/peergos-keys.ts new file mode 100644 index 0000000..b7be93b --- /dev/null +++ b/tests/helpers/peergos-keys.ts @@ -0,0 +1,33 @@ +import { createHash, generateKeyPairSync, KeyObject, sign } from 'crypto'; +import { base58btc } from '../../src/peergos-gate'; +import { cbor, cborEncode } from '../../src/peergos-identity'; + +/** A Peergos-style Ed25519 identity: owner CID + crypto_sign (sig || msg). */ +export interface TestIdentity { + owner: string; + publicKey: Buffer; + keyBlock: Buffer; + sign: (msg: Buffer) => Buffer; +} + +/** Default: identity-multihash owner (key embedded), as Peergos does; hashed: sha2-256 CID. */ +export function makeIdentity(opts: { hashed?: boolean } = {}): TestIdentity { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const pub = (publicKey as KeyObject).export({ format: 'der', type: 'spki' }).subarray(-32); + const keyBlock = cborEncode(cbor.list([cbor.int(1), cbor.bytes(Buffer.from(pub))])); + const cid = opts.hashed + ? // CIDv1, dag-cbor (0x71), sha2-256 multihash + Buffer.concat([Buffer.from([0x01, 0x71, 0x12, 0x20]), createHash('sha256').update(keyBlock).digest()]) + : // CIDv1, dag-cbor, identity multihash (0x00) with the block inline + Buffer.concat([Buffer.from([0x01, 0x71, 0x00, keyBlock.length]), keyBlock]); + return { + owner: 'z' + base58btc(cid), + publicKey: Buffer.from(pub), + keyBlock, + sign: (msg) => Buffer.concat([sign(null, msg, privateKey), msg]), + }; +} + +export function signedTime(id: TestIdentity, ms: number): string { + return id.sign(cborEncode(cbor.int(ms))).toString('hex'); +} diff --git a/tests/peergos-identity.test.ts b/tests/peergos-identity.test.ts new file mode 100644 index 0000000..1ab349a --- /dev/null +++ b/tests/peergos-identity.test.ts @@ -0,0 +1,91 @@ +import Database from 'better-sqlite3'; +import { + cbor, + cborDecode, + cborEncode, + ed25519KeyFromBlock, + ed25519Open, + ownerKeyDigest, + parseOwnerCid, + peergosIdentityKeys, + verifySignedTime, +} from '../src/peergos-identity'; +import { makeIdentity, signedTime } from './helpers/peergos-keys'; + +// grokbot1's owner on peergos.verae.ai (see peergos-gate.test.ts) +const REAL_OWNER = 'z59vuwzfFDouZfoqyaDofTjov6KaaZJeCRVk6GF5mPBAGCEfCGh24MG'; + +describe('peergos identity: CBOR, owner CIDs, signatures', () => { + it('round-trips CBOR values (sorted map keys, large ints, strict decode)', () => { + const v = cbor.map({ z: cbor.int(2n ** 40n), a: cbor.list([cbor.text('x'), cbor.bool(true), cbor.bytes(Buffer.from([1, 2]))]) }); + const enc = cborEncode(v); + const dec = cborDecode(enc); + expect(dec.t).toBe('map'); + expect(cborEncode(dec).equals(enc)).toBe(true); + expect(enc[1]).toBe(0x61); // first key is "a" (sorted) + expect(() => cborDecode(Buffer.concat([enc, Buffer.from([0])]))).toThrow(); + expect(cborEncode(cbor.int(5))).toEqual(Buffer.from([5])); + expect(cborEncode(cbor.bool(false))).toEqual(Buffer.from([0xf4])); + }); + + it('decodes the key embedded in a real Peergos owner (identity multihash) without fetching', async () => { + const c = parseOwnerCid(REAL_OWNER); + expect(c.inlineBlock).toBeDefined(); + const key = ed25519KeyFromBlock(c.inlineBlock!); + expect(key).toHaveLength(32); + const lookup = peergosIdentityKeys('https://p.example', undefined, async () => { + throw new Error('should not fetch'); + }); + expect((await lookup(REAL_OWNER)).equals(key)).toBe(true); + expect(() => ownerKeyDigest(REAL_OWNER)).toThrow(/not sha2-256/); + expect(() => parseOwnerCid('zzzz')).toThrow(); + expect(() => parseOwnerCid('notbase58')).toThrow(); + }); + + it('extracts the sha256 digest from a hashed owner CID', () => { + expect(ownerKeyDigest(makeIdentity({ hashed: true }).owner)).toHaveLength(32); + expect(parseOwnerCid(makeIdentity().owner).inlineBlock).toBeDefined(); + }); + + it('opens crypto_sign messages and rejects tampering', () => { + const id = makeIdentity(); + expect(ed25519KeyFromBlock(id.keyBlock).equals(id.publicKey)).toBe(true); + const signed = id.sign(Buffer.from('hello')); + expect(ed25519Open(id.publicKey, signed)?.toString()).toBe('hello'); + const bad = Buffer.from(signed); + bad[bad.length - 1] ^= 1; + expect(ed25519Open(id.publicKey, bad)).toBeUndefined(); + expect(ed25519Open(makeIdentity().publicKey, signed)).toBeUndefined(); + }); + + it('fetches identity keys, checks the block hash, and caches in sqlite', async () => { + const id = makeIdentity({ hashed: true }); + const other = makeIdentity({ hashed: true }); + const db = new Database(':memory:'); + const urls: string[] = []; + const lookup = peergosIdentityKeys('https://p.example/', db, async (url) => { + urls.push(url); + return url.includes(id.owner) ? id.keyBlock : other.keyBlock; + }); + expect((await lookup(id.owner)).equals(id.publicKey)).toBe(true); + expect(urls[0]).toContain('/api/v0/block/get?arg='); + // wrong block for this owner → rejected + const lying = peergosIdentityKeys('https://p.example', undefined, async () => other.keyBlock); + await expect(lying(id.owner)).rejects.toThrow(/does not match/); + // cached: a second lookup (fresh memory, same db) does not fetch + const again = peergosIdentityKeys('https://p.example', db, async () => { + throw new Error('should not fetch'); + }); + expect((await again(id.owner)).equals(id.publicKey)).toBe(true); + }); + + it('verifies signed times within the 300 s window', async () => { + const id = makeIdentity(); + const keys = async () => id.publicKey; + const now = 1_800_000_000_000; + await expect(verifySignedTime(keys, id.owner, Buffer.from(signedTime(id, now - 10_000), 'hex'), now)).resolves.not.toThrow(); + await expect(verifySignedTime(keys, id.owner, Buffer.from(signedTime(id, now - 400_000), 'hex'), now)).rejects.toThrow(); + const forged = makeIdentity(); + await expect(verifySignedTime(keys, id.owner, Buffer.from(signedTime(forged, now), 'hex'), now)).rejects.toThrow(); + }); +}); diff --git a/tests/peergos-link.test.ts b/tests/peergos-link.test.ts new file mode 100644 index 0000000..7990e45 --- /dev/null +++ b/tests/peergos-link.test.ts @@ -0,0 +1,197 @@ +import request from 'supertest'; +import { buildApp } from '../src/app'; +import { Customer, InMemoryCustomerRepo } from '../src/auth'; +import { InMemoryPeergosLinkRepo, linkMessage } from '../src/peergos-link'; +import { InMemoryPeergosQuotaStore } from '../src/peergos-quota'; +import { binary, decode } from './helpers/cbor-client'; +import { makeIdentity } from './helpers/peergos-keys'; + +const GIB = 2 ** 30; +const ADMIN = { 'x-admin-key': process.env.ADMIN_KEY ?? 'admin-dev-key' }; +const RELAY = { 'x-verae-quota-relay': '1' }; +const PW = 'Correct-horse-9'; + +function fixture() { + const customers = new InMemoryCustomerRepo([ + { id: 'cust-victim', name: 'Victim', email: 'victim@example.com', tierId: 'pro', apiKey: 'k-v' } as Customer, + { id: 'peergos:victim', name: 'victim', tierId: 'pro', apiKey: 'k-pv', parentId: 'cust-victim', accountKind: 'peergos_user' } as Customer, + ]); + const store = new InMemoryPeergosQuotaStore(); + for (const u of ['newuser', 'victim', 'second', 'grokbot-link-1']) store.addUser({ username: u, source: 'signup', firstSeenMs: Date.now() }); + const ids: Record> = { + newuser: makeIdentity(), + victim: makeIdentity(), + second: makeIdentity(), + remote: makeIdentity(), + eve: makeIdentity(), + 'grokbot-link-1': makeIdentity(), + }; + const links = new InMemoryPeergosLinkRepo(); + const { app } = buildApp({ + customers, + peergosQuotaStore: store, + peergosLinks: links, + resolvePeergosOwner: async (u) => ids[u]?.owner, + }); + return { app, customers, store, ids, links }; +} + +async function challenge(app: Parameters[0], username: string, purpose = 'signup') { + const r = await request(app).post('/portal/api/peergos/challenge').send({ peergosUsername: username, purpose }); + return r; +} + +const sign = (id: ReturnType, message: string) => id.sign(Buffer.from(message, 'utf8')).toString('hex'); + +describe('Peergos account linking', () => { + it('formats the challenge message the app signs', () => { + expect(linkMessage({ id: 'abc', username: 'bob', purpose: 'signup', expiresAtMs: 5 })).toBe( + 'verae-billing-link/v1\nusername=bob\npurpose=signup\nchallenge=abc\nexpires=5\nservice=zap.verae.ai', + ); + }); + + it('signs up a Free customer with no card and links the proven Peergos user', async () => { + const { app, ids, customers, links } = fixture(); + const c = await challenge(app, 'NewUser'); + expect(c.status).toBe(200); + expect(c.body.peergosUsername).toBe('newuser'); + expect(c.body.message).toContain('username=newuser'); + const r = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'New User', email: 'new@example.com', password: PW, peergosUsername: 'newuser', challengeId: c.body.challengeId, signature: sign(ids.newuser, c.body.message) }); + expect(r.status).toBe(201); + expect(r.body.token).toBeTruthy(); + expect(r.body.customer).toMatchObject({ tierId: 'free', email: 'new@example.com' }); + expect(r.body.link).toMatchObject({ username: 'newuser', linked: true, tierId: 'free', quotaBytes: 1 * GIB, quotaBasis: 'linked', method: 'app-signup' }); + const child = await customers.findById('peergos:newuser'); + expect(child?.parentId).toBe(r.body.customer.id); + expect(links.get('newuser')?.owner).toBe(ids.newuser.owner); + + // session routes + const mine = await request(app).get('/portal/api/peergos/links').set('Authorization', `Bearer ${r.body.token}`).expect(200); + expect(mine.body.links).toHaveLength(1); + const usage = await request(app).get('/portal/api/billing/usage').set('Authorization', `Bearer ${r.body.token}`).expect(200); + expect(usage.body.storage.users).toEqual([expect.objectContaining({ username: 'newuser', quotaBytes: 1 * GIB })]); + const summary = await request(app).get('/portal/api/billing/summary').set('Authorization', `Bearer ${r.body.token}`).expect(200); + expect(summary.body.plan).toMatchObject({ tierId: 'free', includedStorageGb: 1 }); + expect(summary.body.accessPaused).toBe(false); + + // quota hook now answers from the Free tier + const q = await request(app).get('/quota-admin/quota-by-name?username=newuser').set(RELAY).buffer(true).parse(binary); + expect(decode(q.body)).toBe(1 * GIB); + // public status + expect((await request(app).get('/portal/api/peergos/link-status/newuser')).body).toEqual({ username: 'newuser', linked: true }); + expect((await request(app).get('/portal/api/peergos/link-status/second')).body).toEqual({ username: 'second', linked: false }); + }); + + it("refuses to link someone else's username", async () => { + const { app, ids } = fixture(); + // signed with another identity key + const c = await challenge(app, 'newuser'); + const r = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'Eve', email: 'eve@example.com', password: PW, peergosUsername: 'newuser', challengeId: c.body.challengeId, signature: sign(ids.eve, c.body.message) }); + expect(r.status).toBe(401); + expect(r.body.error).toBe('peergos_signature_invalid'); + // challenge is single use, even after a failure + const again = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'Eve', email: 'eve@example.com', password: PW, peergosUsername: 'newuser', challengeId: c.body.challengeId, signature: sign(ids.newuser, c.body.message) }); + expect(again.body.error).toBe('challenge_invalid_or_expired'); + // a valid signature for a different username's challenge + const c2 = await challenge(app, 'second'); + const swap = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'Eve', email: 'eve@example.com', password: PW, peergosUsername: 'newuser', challengeId: c2.body.challengeId, signature: sign(ids.newuser, c2.body.message) }); + expect(swap.status).toBe(401); + // message tampering (sign a different text) + const c3 = await challenge(app, 'newuser'); + const tampered = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'Eve', email: 'eve@example.com', password: PW, peergosUsername: 'newuser', challengeId: c3.body.challengeId, signature: sign(ids.newuser, c3.body.message + 'x') }); + expect(tampered.body.error).toBe('peergos_signature_invalid'); + // purpose mismatch + const c4 = await challenge(app, 'newuser', 'link'); + const wrongPurpose = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'E', email: 'e2@example.com', password: PW, peergosUsername: 'newuser', challengeId: c4.body.challengeId, signature: sign(ids.newuser, c4.body.message) }); + expect(wrongPurpose.body.error).toBe('challenge_invalid_or_expired'); + // no customer was created + expect((await request(app).get('/portal/api/peergos/link-status/newuser')).body.linked).toBe(false); + }); + + it('refuses a username already linked to another customer, and unknown/remote users', async () => { + const { app, ids } = fixture(); + const c = await challenge(app, 'victim'); + const r = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'V2', email: 'v2@example.com', password: PW, peergosUsername: 'victim', challengeId: c.body.challengeId, signature: sign(ids.victim, c.body.message) }); + expect(r.status).toBe(409); + expect(r.body.error).toBe('peergos_user_linked_to_another_account'); + expect((await challenge(app, 'remote')).body.error).toBe('peergos_user_not_on_this_server'); + expect((await challenge(app, 'ghost')).body.error).toBe('peergos_user_not_found'); + expect((await challenge(app, 'Bad Name!')).body.error).toBe('invalid_peergos_username'); + }); + + it('validates the signup fields before spending the challenge', async () => { + const { app, ids } = fixture(); + const c = await challenge(app, 'newuser'); + const weak = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'N', email: 'n@example.com', password: 'short', peergosUsername: 'newuser', challengeId: c.body.challengeId, signature: sign(ids.newuser, c.body.message) }); + expect(weak.status).toBe(400); + const ok = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'N', email: 'n@example.com', password: PW, peergosUsername: 'newuser', challengeId: c.body.challengeId, signature: sign(ids.newuser, c.body.message) }); + expect(ok.status).toBe(201); + const c2 = await challenge(app, 'second'); + const dup = await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'N', email: 'n@example.com', password: PW, peergosUsername: 'second', challengeId: c2.body.challengeId, signature: sign(ids.second, c2.body.message) }); + expect(dup.status).toBe(409); + }); + + it('links to an existing account after portal login, and only staff can unlink', async () => { + const { app, ids } = fixture(); + const s = await request(app).post('/portal/api/signup').send({ name: 'Existing', email: 'ex@example.com', password: PW }).expect(201); + const login = await request(app).post('/portal/api/login').send({ email: 'ex@example.com', password: PW }).expect(200); + const token = login.body.token; + const c = await challenge(app, 'second', 'link'); + await request(app) + .post('/portal/api/peergos/link') + .send({ peergosUsername: 'second', challengeId: c.body.challengeId, signature: sign(ids.second, c.body.message) }) + .expect(401); // needs a session + const c2 = await challenge(app, 'second', 'link'); + const l = await request(app) + .post('/portal/api/peergos/link') + .set('Authorization', `Bearer ${token}`) + .send({ peergosUsername: 'second', challengeId: c2.body.challengeId, signature: sign(ids.second, c2.body.message) }) + .expect(200); + expect(l.body).toMatchObject({ linked: true, customerId: s.body.customer.id, method: 'app-link' }); + + // customers cannot unlink; there is no portal route for it + await request(app).delete('/portal/api/peergos/links/second').set('Authorization', `Bearer ${token}`).expect(404); + await request(app).delete('/admin/api/peergos/links/second').expect(403); + const staffView = await request(app).get('/admin/api/peergos/links/second').set(ADMIN).expect(200); + expect(staffView.body).toMatchObject({ linked: true, record: expect.objectContaining({ method: 'app-link' }) }); + const u = await request(app).delete('/admin/api/peergos/links/second').set(ADMIN).expect(200); + expect(u.body).toMatchObject({ unlinked: true, formerCustomerId: s.body.customer.id }); + expect((await request(app).get('/portal/api/peergos/link-status/second')).body.linked).toBe(false); + const all = await request(app).get('/admin/api/peergos/links').set(ADMIN).expect(200); + expect(all.body.links[0]).toMatchObject({ username: 'second', unlinkedBy: expect.any(String) }); + await request(app).delete('/admin/api/peergos/links/second').set(ADMIN).expect(404); + }); + + it('switches an internal grokbot test user from 5 GiB to the Free 1 GiB once linked', async () => { + const { app, ids } = fixture(); + const q = async () => + decode((await request(app).get('/quota-admin/quota-by-name?username=grokbot-link-1').set(RELAY).buffer(true).parse(binary)).body); + expect(await q()).toBe(5 * GIB); + const c = await challenge(app, 'grokbot-link-1'); + await request(app) + .post('/portal/api/peergos/signup') + .send({ name: 'Link test', email: 'lt@example.com', password: PW, peergosUsername: 'grokbot-link-1', challengeId: c.body.challengeId, signature: sign(ids['grokbot-link-1'], c.body.message) }) + .expect(201); + expect(await q()).toBe(1 * GIB); + }); +}); diff --git a/tests/peergos-quota.test.ts b/tests/peergos-quota.test.ts new file mode 100644 index 0000000..db3847b --- /dev/null +++ b/tests/peergos-quota.test.ts @@ -0,0 +1,178 @@ +import request from 'supertest'; +import { buildApp } from '../src/app'; +import { Customer, InMemoryCustomerRepo } from '../src/auth'; +import { InMemoryPeergosQuotaStore, PeergosQuotaService, quotaPolicyFromEnv, SPACE_REQUEST_MESSAGE } from '../src/peergos-quota'; +import { cbor, cborEncode } from '../src/peergos-identity'; +import { binary, decode } from './helpers/cbor-client'; +import { makeIdentity, signedTime } from './helpers/peergos-keys'; + +const GIB = 2 ** 30; +const ADMIN = { 'x-admin-key': process.env.ADMIN_KEY ?? 'admin-dev-key' }; +const RELAY = { 'x-verae-quota-relay': '1' }; + +function fixture() { + const customers = new InMemoryCustomerRepo([ + { id: 'cust-pro', name: 'Pro Co', tierId: 'pro', apiKey: 'k-pro' } as Customer, + { id: 'cust-free', name: 'Free Co', tierId: 'free', apiKey: 'k-free' } as Customer, + { id: 'cust-big', name: 'Big', tierId: 'free', apiKey: 'k-big', includedStorageGbOverride: 20 } as Customer, + { id: 'peergos:alice', name: 'alice', tierId: 'pro', apiKey: 'k-a', parentId: 'cust-pro', accountKind: 'peergos_user' } as Customer, + { id: 'peergos:carol', name: 'carol', tierId: 'free', apiKey: 'k-c', parentId: 'cust-free', accountKind: 'peergos_user' } as Customer, + { id: 'peergos:dave', name: 'dave', tierId: 'free', apiKey: 'k-d', parentId: 'cust-big', accountKind: 'peergos_user' } as Customer, + ]); + const store = new InMemoryPeergosQuotaStore(); + const t0 = Date.UTC(2026, 9, 6); + for (const [username, legacy] of [ + ['alice', 5 * GIB], + ['carol', 0], + ['marchon', 200 * GIB], + ['bob', undefined], + ['grokbot1', 5 * GIB], + ] as const) { + store.addUser({ username, legacyQuotaBytes: legacy, source: 'import', firstSeenMs: t0 }); + } + const ids: Record> = { bob: makeIdentity(), carol: makeIdentity(), eve: makeIdentity() }; + const resolveOwner = async (u: string) => ids[u]?.owner; + const { app } = buildApp({ customers, peergosQuotaStore: store, resolvePeergosOwner: resolveOwner }); + return { app, store, customers, ids, t0 }; +} + +const cborGet = (app: Parameters[0], path: string, headers: Record = RELAY) => + request(app).get(path).set(headers).buffer(true).parse(binary); + +describe('peergos quota policy', () => { + it('reads env overrides and defaults', () => { + const p = quotaPolicyFromEnv({ PEERGOS_INTERNAL_PREFIXES: 'grokbot,qa-', PEERGOS_UNLINKED_QUOTA_BYTES: '7' }); + expect(p.internalPrefixes).toEqual(['grokbot', 'qa-']); + expect(p.unlinkedQuotaBytes).toBe(7); + expect(p.internalQuotaBytes).toBe(5 * GIB); + expect(p.graceDays).toBe(30); + }); + + it('decides quotas from the linked tier, internal list, legacy floor and overrides', async () => { + const { customers, store, t0 } = fixture(); + const svc = new PeergosQuotaService({ + store, + customers, + tiers: () => [ + { id: 'free', name: 'Free', monthlyCreditCents: 100, includedStorageGb: 1 } as never, + { id: 'pro', name: 'Pro', monthlyCreditCents: 0, includedStorageGb: 50 } as never, + ], + now: () => t0, + }); + expect(await svc.decide('alice')).toMatchObject({ basis: 'linked', quotaBytes: 50 * GIB, tierId: 'pro' }); + expect(await svc.decide('carol')).toMatchObject({ basis: 'linked', quotaBytes: 1 * GIB }); + expect(await svc.decide('dave')).toMatchObject({ basis: 'linked', quotaBytes: 20 * GIB }); // customer override + expect(await svc.decide('marchon')).toMatchObject({ basis: 'unlinked', quotaBytes: 200 * GIB }); // legacy floor + expect(await svc.decide('grokbot1')).toMatchObject({ basis: 'internal', quotaBytes: 5 * GIB }); + const bob = await svc.decide('bob'); + expect(bob).toMatchObject({ basis: 'unlinked', quotaBytes: 1 * GIB }); + expect(bob.graceEndsAtMs).toBe(t0 + 30 * 86_400_000); + expect(await svc.decide('nobody')).toMatchObject({ basis: 'unknown', quotaBytes: 0 }); + store.setOverride({ username: 'carol', quotaBytes: 3 * GIB, setAtMs: t0 }); + svc.invalidate(); + expect((await svc.decide('carol')).quotaBytes).toBe(3 * GIB); + store.markRemoved('bob', t0); + svc.invalidate(); + expect(await svc.decide('bob')).toMatchObject({ basis: 'removed', quotaBytes: 0 }); + expect(await svc.knownUsernames()).toEqual(['alice', 'carol', 'dave', 'grokbot1', 'marchon']); + }); +}); + +describe('/quota-admin (Peergos HttpQuotaAdmin)', () => { + it('is hidden without the Caddy relay header', async () => { + const { app } = fixture(); + await request(app).get('/quota-admin/usernames').expect(404); + }); + + it('answers list, quota and signup calls in CBOR', async () => { + const { app, store } = fixture(); + const users = await cborGet(app, '/quota-admin/usernames').expect(200); + expect(users.headers['content-type']).toMatch(/application\/cbor/); + expect(decode(users.body)).toEqual(['alice', 'bob', 'carol', 'dave', 'grokbot1', 'marchon']); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name?username=alice')).body)).toBe(50 * GIB); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name?username=carol')).body)).toBe(1 * GIB); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name?username=marchon')).body)).toBe(200 * GIB); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name?username=ghost')).body)).toBe(0); + expect(decode((await cborGet(app, '/quota-admin/signups')).body)).toEqual({ f: true, p: false }); + + // free signup: allowed adds the user with the unlinked Free quota + expect(decode((await cborGet(app, '/quota-admin/allowed?username=newbie&token=')).body)).toBe(true); + expect(store.getUser('newbie')?.source).toBe('signup'); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name?username=newbie')).body)).toBe(1 * GIB); + // internal test accounts keep 5 GiB + await cborGet(app, '/quota-admin/allowed?username=grokbot-signup-9&token='); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name?username=grokbot-signup-9')).body)).toBe(5 * GIB); + + const when = new Date(Date.now() + 60_000).toISOString().replace('Z', ''); + expect(decode((await cborGet(app, `/quota-admin/quota-by-name-time?username=newbie&time=${when}`)).body)).toBe(true); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name-time?username=newbie&time=2020-01-01T00:00')).body)).toBe(false); + + // removal + expect(decode((await cborGet(app, '/quota-admin/quota-remove?username=newbie')).body)).toBe(true); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name?username=newbie')).body)).toBe(0); + expect(decode((await cborGet(app, '/quota-admin/allowed?username=newbie&token=')).body)).toBe(false); + }); + + it('handles signup tokens like UserQuotas', async () => { + const { app, store } = fixture(); + expect(decode((await cborGet(app, '/quota-admin/token-add?token=tok1')).body)).toBe(true); + expect(decode((await cborGet(app, '/quota-admin/token-list')).body)).toEqual(['tok1']); + expect(decode((await cborGet(app, '/quota-admin/token-remove?username=zed&token=tok1')).body)).toBe(true); + expect(store.getUser('zed')?.source).toBe('token'); + expect(decode((await cborGet(app, '/quota-admin/token-remove?username=zed2&token=tok1')).body)).toBe(false); + expect(decode((await cborGet(app, '/quota-admin/token-list')).body)).toEqual([]); + }); + + it('verifies the owner signature on quota / payment-properties / request', async () => { + const { app, ids } = fixture(); + const bob = ids.bob; + const auth = signedTime(bob, Date.now()); + const q = await cborGet(app, `/quota-admin/quota?owner=${bob.owner}&auth=${auth}`).expect(200); + expect(decode(q.body)).toBe(1 * GIB); + const pp = await cborGet(app, `/quota-admin/payment-properties?owner=${bob.owner}&new-client-secret=false&auth=${auth}`); + expect(decode(pp.body)).toMatchObject({ freeQuota: 1 * GIB, desiredQuota: 0, annual: false }); + + // someone else's signature for bob's owner → rejected + const forged = signedTime(ids.eve, Date.now()); + const bad = await cborGet(app, `/quota-admin/quota?owner=${bob.owner}&auth=${forged}`).expect(400); + expect(decodeURIComponent(bad.headers['trailer'])).toMatch(/signature|sign/i); + // stale time → rejected + await cborGet(app, `/quota-admin/quota?owner=${bob.owner}&auth=${signedTime(bob, Date.now() - 600_000)}`).expect(400); + // owner we don't know + const eveAuth = signedTime(ids.eve, Date.now()); + await cborGet(app, `/quota-admin/quota?owner=${ids.eve.owner}&auth=${eveAuth}`).expect(400); + + // space request: signed {u,s,a,t,p}; we answer with current quota + where to upgrade + const req = bob + .sign( + cborEncode( + cbor.map({ u: cbor.text('bob'), s: cbor.int(10 * GIB), a: cbor.bool(false), t: cbor.int(Date.now()), p: cbor.bytes(Buffer.alloc(0)) }), + ), + ) + .toString('hex'); + const r = await cborGet(app, `/quota-admin/request?owner=${bob.owner}&req=${req}&usage=0`).expect(200); + expect(decode(r.body)).toMatchObject({ freeQuota: 1 * GIB, err: SPACE_REQUEST_MESSAGE }); + await cborGet(app, '/quota-admin/create-paid?username=x').expect(400); + await cborGet(app, '/quota-admin/nope').expect(400); + }); + + it('lets staff import legacy quotas/tokens and set overrides', async () => { + const { app, store } = fixture(); + const imp = await request(app) + .post('/admin/api/peergos/quotas/import') + .set(ADMIN) + .send({ users: [{ username: 'legacy1', quotaBytes: 9 * GIB }, { username: 'bob', quotaBytes: 2 * GIB }], tokens: ['t-1'] }) + .expect(200); + expect(imp.body).toMatchObject({ added: 1, updated: 1, tokensAdded: 1 }); + expect(store.getUser('bob')?.legacyQuotaBytes).toBe(2 * GIB); + const v = await request(app).get('/admin/api/peergos/quotas/legacy1').set(ADMIN).expect(200); + expect(v.body).toMatchObject({ quotaBytes: 9 * GIB }); + await request(app).put('/admin/api/peergos/quotas/carol/override').set(ADMIN).send({ quotaBytes: 4 * GIB, reason: 'auto-raise' }).expect(200); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name?username=carol')).body)).toBe(4 * GIB); + await request(app).delete('/admin/api/peergos/quotas/carol/override').set(ADMIN).expect(200); + expect(decode((await cborGet(app, '/quota-admin/quota-by-name?username=carol')).body)).toBe(1 * GIB); + const list = await request(app).get('/admin/api/peergos/quotas').set(ADMIN).expect(200); + expect(JSON.stringify(list.body)).toContain('legacy1'); + await request(app).get('/admin/api/peergos/quotas').expect(403); + }); +}); diff --git a/tests/peergos-sqlite-stores.test.ts b/tests/peergos-sqlite-stores.test.ts new file mode 100644 index 0000000..b7b0017 --- /dev/null +++ b/tests/peergos-sqlite-stores.test.ts @@ -0,0 +1,51 @@ +import Database from 'better-sqlite3'; +import { SqliteCustomerFlagRepo } from '../src/customer-flags'; +import { SqlitePeergosLinkRepo } from '../src/peergos-link'; +import { SqlitePeergosQuotaStore } from '../src/peergos-quota'; + +describe('additive sqlite tables for links, quotas and flags', () => { + it('persists quota users, tokens, overrides and owner mapping', () => { + const db = new Database(':memory:'); + const s = new SqlitePeergosQuotaStore(db); + new SqlitePeergosQuotaStore(db); // idempotent DDL + expect(s.addUser({ username: 'a', legacyQuotaBytes: 5, source: 'import', firstSeenMs: 1 })).toBe(true); + expect(s.addUser({ username: 'a', source: 'signup', firstSeenMs: 2 })).toBe(false); + expect(s.getUser('a')).toMatchObject({ legacyQuotaBytes: 5, source: 'import', firstSeenMs: 1 }); + s.setLegacyQuota('a', 9); + expect(s.getUser('a')?.legacyQuotaBytes).toBe(9); + s.addUser({ username: 'b', source: 'signup', firstSeenMs: 3 }); + s.markRemoved('b', 4); + expect(s.countActive()).toBe(1); + expect(s.listUsers().map((u) => u.username)).toEqual(['a']); + expect(s.listUsers(true)).toHaveLength(2); + expect(s.addToken('t', 1)).toBe(true); + expect(s.hasToken('t')).toBe(true); + expect(s.consumeToken('t', 'c', 2)).toBe(true); + expect(s.consumeToken('t', 'd', 3)).toBe(false); + expect(s.listTokens()).toEqual([]); + s.addToken('u', 1); + expect(s.deleteToken('u', 2)).toBe(true); + expect(s.hasToken('u')).toBe(false); + s.setOverride({ username: 'a', quotaBytes: 7, setAtMs: 1, setBy: 'x' }); + expect(s.getOverride('a')).toMatchObject({ quotaBytes: 7, setBy: 'x' }); + expect(s.clearOverride('a')).toBe(true); + expect(s.getOverride('a')).toBeUndefined(); + db.prepare('INSERT INTO peergos_owner_keys (username, owner, resolved_at_ms) VALUES (?, ?, ?)').run('a', 'zOwner', 1); + expect(s.usernameForOwner('zOwner')).toBe('a'); + }); + + it('persists link records and customer flags', () => { + const db = new Database(':memory:'); + const l = new SqlitePeergosLinkRepo(db); + l.record({ username: 'a', customerId: 'c1', owner: 'z', method: 'app-signup', linkedAtMs: 1, linkedBy: 'c1' }); + l.markUnlinked('a', 2, 'staff'); + expect(l.get('a')).toMatchObject({ unlinkedAtMs: 2, unlinkedBy: 'staff' }); + l.record({ username: 'a', customerId: 'c2', method: 'app-link', linkedAtMs: 3 }); + expect(l.get('a')).toMatchObject({ customerId: 'c2', unlinkedAtMs: undefined }); + expect(l.list()).toHaveLength(1); + const f = new SqliteCustomerFlagRepo(db); + f.set({ customerId: 'c2', test: true, noInvoice: true, note: 'e2e', updatedAtMs: 1 }); + expect(f.get('c2')).toMatchObject({ test: true, noInvoice: true, note: 'e2e' }); + expect(f.list()).toHaveLength(1); + }); +}); -- 2.54.0