Peergos account linking + billing-sourced quotas (quota-admin hook) + customer flags #5

Merged
marchon merged 1 commit from feat/peergos-link-quota into overdue-negative-balance-only 2026-10-06 16:26:37 -04:00
20 changed files with 2527 additions and 61 deletions

View file

@ -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) |

View file

@ -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:<username>` 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=<u>\npurpose=<p>\nchallenge=<id>\nexpires=<ms>\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 <portal session>` | 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:<username>` 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:<u>` 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.

View file

@ -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)`

View file

@ -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');

View file

@ -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({

View file

@ -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<number | undefined>;
}
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',
},

137
src/customer-flags.ts Normal file
View file

@ -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<string, CustomerFlags>();
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<string, unknown>): 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<string, unknown>
| 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<string, unknown>[]).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;
}

View file

@ -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<void> {
// 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<void> {
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';

334
src/peergos-identity.ts Normal file
View file

@ -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<string, Cbor> }
| { 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<string, Cbor>): 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<string, Cbor>();
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<Buffer>;
/**
* 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<Buffer> = 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<string, Buffer>();
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<number> {
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;
}

457
src/peergos-link.ts Normal file
View file

@ -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<LinkChallenge, 'message'>): 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<string, LinkChallenge>();
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<string, PeergosLinkRecord>();
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<string, unknown>): 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<string, unknown>
| undefined;
return r ? this.map(r) : undefined;
}
list() {
return (this.db.prepare('SELECT * FROM peergos_links ORDER BY linked_at_ms').all() as Record<string, unknown>[]).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<string | undefined>;
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<LinkChallenge> {
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<Customer | undefined> {
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<void> {
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;
}

698
src/peergos-quota.ts Normal file
View file

@ -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:<user> 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<string, QuotaUser>();
tokens = new Map<string, { consumedBy?: string; deleted?: boolean }>();
overrides = new Map<string, QuotaOverride>();
owners = new Map<string, string>(); // 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<string, unknown> | 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<string, unknown> | 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<string, unknown>[];
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<string, unknown>
| 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<string | undefined>;
identityKeys?: IdentityKeyLookup;
now?: () => number;
log?: (m: string) => void;
}
const CACHE_TTL_MS = 15_000;
export class PeergosQuotaService {
readonly policy: QuotaPolicy;
private cache = new Map<string, { at: number; d: QuotaDecision }>();
private childIndex: { at: number; byUser: Map<string, { child: Customer; parent?: Customer }> } | 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<string, { child: Customer; parent?: Customer }>();
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<QuotaDecision> {
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<QuotaDecision> {
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<number> {
return (await this.decide(username)).quotaBytes;
}
async knownUsernames(): Promise<string[]> {
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<boolean> {
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<string | undefined> {
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<void> {
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<string, Cbor> = {
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;
}

View file

@ -66,7 +66,7 @@ export interface PortalDeps {
billing?: Omit<BillingApiDeps, 'customers' | 'usage' | 'invoices' | 'tiers'>;
}
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<PortalDeps, 'sessions' | 'customers' | 'now'>): RequestHandler {
return (req, res, next) => {
void (async () => {
const bearer = req.header('authorization');
@ -152,6 +152,73 @@ async function save(deps: PortalDeps, customer: Customer): Promise<void> {
await deps.customers.save(customer);
}
/** Field and duplicate-email checks for portalSignup; undefined = OK. */
export async function validatePortalSignup(
deps: Pick<PortalDeps, 'customers'>,
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<PortalDeps, 'customers' | 'sessions'>,
body: { name?: unknown; email?: unknown; password?: unknown },
credits: CreditLedgerLike,
ttlMs: number,
): Promise<PortalSignupResult> {
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) => {

View file

@ -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';
}

View file

@ -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);
});
});

View file

@ -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));

View file

@ -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');
}

View file

@ -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();
});
});

197
tests/peergos-link.test.ts Normal file
View file

@ -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<string, ReturnType<typeof makeIdentity>> = {
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<typeof request>[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<typeof makeIdentity>, 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);
});
});

178
tests/peergos-quota.test.ts Normal file
View file

@ -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<string, ReturnType<typeof makeIdentity>> = { 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<typeof request>[0], path: string, headers: Record<string, string> = 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);
});
});

View file

@ -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);
});
});