Peergos account linking + billing-sourced quotas (quota-admin hook) + customer flags #5
20 changed files with 2527 additions and 61 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
64
src/app.ts
64
src/app.ts
|
|
@ -53,6 +53,16 @@ import { BILLING_SUBJECTS, natsAdjust, natsPublish, natsStatement } from './bill
|
|||
import { buildRegistrationPdf, registrationFilename } from './registration-pdf';
|
||||
import { ApiTokenRepo, InMemoryApiTokenRepo } from './db/api-token-repo';
|
||||
import { seoRouter } from './seo';
|
||||
import { CustomerFlagRepo, customerFlagsAdminRouter, InMemoryCustomerFlagRepo } from './customer-flags';
|
||||
import { IdentityKeyLookup, peergosIdentityKeys } from './peergos-identity';
|
||||
import { InMemoryPeergosLinkRepo, PeergosLinkRepo, peergosLinkRouter, PeergosLinkService, peergosLinkStaffRouter } from './peergos-link';
|
||||
import {
|
||||
InMemoryPeergosQuotaStore,
|
||||
peergosQuotaAdminRouter,
|
||||
PeergosQuotaService,
|
||||
peergosQuotaStaffRouter,
|
||||
PeergosQuotaStore,
|
||||
} from './peergos-quota';
|
||||
|
||||
/**
|
||||
* Parse export formats from body/query.
|
||||
|
|
@ -267,6 +277,14 @@ export interface AppDeps {
|
|||
/** Stripe client for card management / pay-now; undefined → 503 on those routes. */
|
||||
billingStripe?: BillingStripe;
|
||||
stripePublishableKey?: string;
|
||||
/** Peergos external quota hook + account linking (additive tables on billing.db in production). */
|
||||
peergosQuota?: PeergosQuotaService;
|
||||
peergosQuotaStore?: PeergosQuotaStore;
|
||||
peergosLinks?: PeergosLinkRepo;
|
||||
peergosIdentityKeys?: IdentityKeyLookup;
|
||||
customerFlags?: CustomerFlagRepo;
|
||||
/** Test hook: relay header requirement on /quota-admin (default true). */
|
||||
quotaAdminRequireRelayHeader?: boolean;
|
||||
}
|
||||
|
||||
const SPEC_PATH = path.join(PROJECT_ROOT, 'openapi.yaml');
|
||||
|
|
@ -384,6 +402,43 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
// Caddy forward_auth target for peergos.verae.ai (design §3.11). Mounted before
|
||||
// the OpenAPI validator; ns1 Caddy only lets ns2 reach it.
|
||||
app.use('/peergos-gate', peergosGateRouter(peergosGate, gateDeps));
|
||||
const resolveOwner = deps.resolvePeergosOwner ?? (async () => undefined);
|
||||
const identityKeys: IdentityKeyLookup =
|
||||
deps.peergosIdentityKeys ?? peergosIdentityKeys(process.env.PEERGOS_URL ?? 'https://peergos.verae.ai');
|
||||
const peergosQuota =
|
||||
deps.peergosQuota ??
|
||||
new PeergosQuotaService({
|
||||
store: deps.peergosQuotaStore ?? new InMemoryPeergosQuotaStore(),
|
||||
customers,
|
||||
tiers: () => pricingStore.getTiers(),
|
||||
resolveOwner,
|
||||
identityKeys,
|
||||
log: (m: string) => console.warn(m),
|
||||
});
|
||||
// Peergos external quota hook (-quota-admin-address, paid mode). ns1 Caddy relays
|
||||
// only ns2's calls on a dedicated listener and sets X-Verae-Quota-Relay.
|
||||
app.use(
|
||||
'/quota-admin',
|
||||
peergosQuotaAdminRouter(peergosQuota, { requireRelayHeader: deps.quotaAdminRequireRelayHeader ?? true }),
|
||||
);
|
||||
const peergosLinks = deps.peergosLinks ?? new InMemoryPeergosLinkRepo();
|
||||
const sessions = deps.sessions ?? new InMemorySessionRepo();
|
||||
const linkDeps = {
|
||||
customers,
|
||||
sessions,
|
||||
credits,
|
||||
links: peergosLinks,
|
||||
resolveOwner,
|
||||
identityKeys,
|
||||
quota: peergosQuota,
|
||||
onChange: () => {
|
||||
peergosQuota.invalidate();
|
||||
peergosGate.invalidate();
|
||||
},
|
||||
};
|
||||
let linkSvc: PeergosLinkService | undefined;
|
||||
const peergosLinkService = () => (linkSvc ??= new PeergosLinkService(linkDeps));
|
||||
const customerFlags = deps.customerFlags ?? new InMemoryCustomerFlagRepo();
|
||||
const billingBase = {
|
||||
gate: peergosGate,
|
||||
holds: billingHolds,
|
||||
|
|
@ -391,6 +446,7 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
purchaseOrders,
|
||||
stripe: deps.billingStripe,
|
||||
publishableKey: deps.stripePublishableKey,
|
||||
peergosQuota: async (u: string) => (await peergosQuota.decide(u)).quotaBytes,
|
||||
};
|
||||
app.use(
|
||||
'/admin/api',
|
||||
|
|
@ -400,11 +456,14 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
adminRouter(
|
||||
pricingStore,
|
||||
customers,
|
||||
{ usage, invoices, users: adminUsers, mailer: deps.mailer },
|
||||
{ usage, invoices, users: adminUsers, mailer: deps.mailer, flags: customerFlags },
|
||||
credits,
|
||||
deps.channelRates,
|
||||
),
|
||||
peergosGateAdminRouter(peergosGate, gateDeps),
|
||||
peergosQuotaStaffRouter(peergosQuota),
|
||||
peergosLinkStaffRouter(peergosLinkService(), peergosLinks),
|
||||
customerFlagsAdminRouter(customerFlags, customers),
|
||||
billingAdminRouter({
|
||||
...billingBase,
|
||||
customers,
|
||||
|
|
@ -416,7 +475,6 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin')));
|
||||
app.use('/client', express.static(path.join(PROJECT_ROOT, 'client')));
|
||||
|
||||
const sessions = deps.sessions ?? new InMemorySessionRepo();
|
||||
const apiTokens: ApiTokenRepo = deps.apiTokens ?? new InMemoryApiTokenRepo();
|
||||
const qr =
|
||||
deps.qr ??
|
||||
|
|
@ -428,6 +486,8 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
// Safe default for tests/dev: credits immediately, no Stripe call.
|
||||
reload: async (_customer, amountCents) => ({ mode: 'dev', creditedCents: amountCents }),
|
||||
};
|
||||
// App signup: Free customer + proven Peergos link (before the session-protected portal).
|
||||
app.use('/portal/api/peergos', peergosLinkRouter(peergosLinkService(), linkDeps));
|
||||
app.use(
|
||||
'/portal/api',
|
||||
portalRouter({
|
||||
|
|
|
|||
|
|
@ -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
137
src/customer-flags.ts
Normal 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;
|
||||
}
|
||||
22
src/index.ts
22
src/index.ts
|
|
@ -17,6 +17,10 @@ import {
|
|||
stripeBillingClient,
|
||||
} from './billing-api';
|
||||
import { peergosOwnerResolver, SqliteBillingHoldRepo } from './peergos-gate';
|
||||
import { SqliteCustomerFlagRepo } from './customer-flags';
|
||||
import { peergosIdentityKeys } from './peergos-identity';
|
||||
import { SqlitePeergosLinkRepo } from './peergos-link';
|
||||
import { PeergosQuotaService, quotaPolicyFromEnv, SqlitePeergosQuotaStore } from './peergos-quota';
|
||||
|
||||
loadEnv({ path: path.join(PROJECT_ROOT, '.env') });
|
||||
|
||||
|
|
@ -63,6 +67,20 @@ async function main(): Promise<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
334
src/peergos-identity.ts
Normal 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
457
src/peergos-link.ts
Normal 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
698
src/peergos-quota.ts
Normal 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;
|
||||
}
|
||||
127
src/portal.ts
127
src/portal.ts
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
30
tests/customer-flags.test.ts
Normal file
30
tests/customer-flags.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
26
tests/helpers/cbor-client.ts
Normal file
26
tests/helpers/cbor-client.ts
Normal 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));
|
||||
33
tests/helpers/peergos-keys.ts
Normal file
33
tests/helpers/peergos-keys.ts
Normal 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');
|
||||
}
|
||||
91
tests/peergos-identity.test.ts
Normal file
91
tests/peergos-identity.test.ts
Normal 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
197
tests/peergos-link.test.ts
Normal 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
178
tests/peergos-quota.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
tests/peergos-sqlite-stores.test.ts
Normal file
51
tests/peergos-sqlite-stores.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue