diff --git a/AGENTS.md b/AGENTS.md index e06bb60..9fe56d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,9 @@ Instructions for AI coding agents working in this repository. - `src/platform-store.ts`: scoped records, serialized writes and encryption - `src/backups.ts`: encrypted jobs, schedules, downloads and verification - `src/postgres-archive.ts`: bounded portable application-data archives for serverless hosts -- `src/token-contract.ts`: legacy v4/v2, opt-in import-license v5/trial v3, and separate recovery v1 schemas +- `src/token-contract.ts`: clean v1 license, trial and separate recovery schemas - `src/import-licensing.ts`: opt-in cumulative allocations, atomic renewal sales, account ownership and approved recovery -- `src/db.ts`: dual-engine historical ledger repository and migrations +- `src/db.ts`: current dual-engine scoped ledger, audit, backups and fresh v1 schema - `src/sign.ts`: Ed25519 signing and token helpers - `src/auth.ts`: Better Auth setup for the console (admin plugin, role backfill) - `src/permissions.ts`: operator roles (admin/support) and auth.admin feature scope @@ -73,7 +73,7 @@ Instructions for AI coding agents working in this repository. - Do not change token signing format without coordinating client verification changes. - Preserve fail-closed behavior when signing key is missing. - Token payload changes require a version bump (`v`) and a compat story. -- Import licensing is explicit per-product opt-in. Never silently adopt a legacy license, infer zero from missing local usage, refill on payment/refresh, or accept a recovery artifact as a license. Server mutations and licensing events share the existing transaction; SDK import mutations require host-supplied atomic CAS and a shared media transaction. Preserve unknown lifetime history as unknown after recovery. +- Import licensing is explicit per-product opt-in. There are no previous-schema imports or token/state conversion paths. Never infer zero from missing local usage, refill on payment/refresh, or accept a recovery artifact as a license. Server mutations and licensing events share the existing transaction; SDK import mutations require host-supplied atomic CAS and a shared media transaction. Preserve unknown lifetime history as unknown after recovery. - Run `vp run typecheck` and `vp run test` after code changes before proposing completion. - Config schema changes must keep `packages/create-keyforge` generating valid output; `test/create-keyforge.test.ts` is what catches it. diff --git a/docs/api.md b/docs/api.md index c27714e..f49e494 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,7 +4,7 @@ Vercel-only internal worker: `GET /api/internal/backups/tick` requires `Authoriz For dependent apps, use the [client OpenAPI document](client-openapi.json) and [TypeScript SDK guide](../packages/keyforge-client/README.md). The machine-readable contract covers current public routes and explicitly separates installation, private-request and trusted-backend credentials. Its request/response examples are exercised against the server by `test/client-sdk.test.ts`; regenerate with `vp run docs:client-api`. -This is the fresh product-scoped contract. Legacy unscoped routes are removed. License tokens are v4; trial tokens are v2. All timestamps are ISO 8601. API responses are non-cacheable. +This is the fresh product-scoped v1 contract. License, trial and usage-recovery tokens are v1; no older formats are accepted. All timestamps are ISO 8601. API responses are non-cacheable. ## Authentication and roles @@ -21,7 +21,7 @@ Better Auth endpoints live under /api/auth/* (GET/POST): sign-in/email, sign-out Product IDs are lowercase slugs. Definition IDs permit letters, digits, underscore, dot and hyphen (up to 64). Updates to existing catalog, product, meter, schedule and support records require their current integer revision; stale values return revision_conflict. Archive records instead of deleting data used by history. - Product create: id/name; patch: name/archived/graceDays/trial/revision. Trial contains enabled, nullable nonnegative integer allowance and nullable positive durationDays. -- Definition PUT: id/type/description/archived, optional defaultValue/minimum/maximum/revision. Types are number/string/boolean/mixed; mixed legacy definitions must be resolved before new grants. +- Definition PUT: id/type/description/archived, optional defaultValue/minimum/maximum/revision. Types are number/string/boolean. - Plan PUT: id/description/entitlements/durationDays/activationLimit/archived/revision, price (integer santim or null) and currency (ETB). Configure a price before issuance. Entitlements must match active definitions. durationDays is positive or null. - Catalog import: catalog:{version:1,definitions,plans}, preview boolean; apply also requires the preview fingerprint. Export excludes secrets and licenses. - License POST: requestId UUID and plan required; issuedTo, installationId or licenseRequestId, subjectKind installation/service, planRevision, expectedPrice optional. Optional payment:{date,method,reference} records the full plan price received on today’s UTC date. Terms cannot be overridden. Atomically returns HTTP 201 with license/key, immutable ETB sale and delivery status. Exact retries reuse the request ID; changed payloads conflict. No price means issuance is refused. @@ -88,7 +88,6 @@ The table is generated from an isolated server's route registration. Product-sco | POST | `/admin/products/:productId/licenses/:id/deactivate` | | GET | `/admin/products/:productId/licenses/:id/delivery` | | GET | `/admin/products/:productId/licenses/:id/import-account` | -| POST | `/admin/products/:productId/licenses/:id/import-account` | | POST | `/admin/products/:productId/licenses/:id/key` | | POST | `/admin/products/:productId/licenses/:id/renew` | | POST | `/admin/products/:productId/licenses/:id/replace-key` | @@ -196,7 +195,6 @@ Domain errors use error/message/retryable/serverTime; rate limiting may include | `idempotency_conflict` | | `import_account_missing` | | `import_account_required` | -| `import_migration_required` | | `initial_sale_exists` | | `installation_unauthorized` | | `insufficient_role` | @@ -214,7 +212,6 @@ Domain errors use error/message/retryable/serverTime; rate limiting may include | `invalid_request_secret` | | `invalid_support_request` | | `key_already_staged` | -| `key_mismatch` | | `key_required` | | `license_` | | `license_expired` | @@ -300,7 +297,7 @@ Financial routes require an admin session or privileged admin bearer token; supp Sale and entry UUIDs provide idempotency: retries with identical normalized payloads return the original record; changed payloads return `idempotency_conflict`. Dates cannot be future-dated, and entries cannot precede their sale. Overpayments, excessive credits, and refunds exceeding the referenced receipt are rejected. Historical daily balances cannot become negative through backdating. Financial records are append-only through these APIs; corrections retain history using refunds, credits and new sale records. -A cash refund reopens the amount owed. A credit reduces the agreed charge; when cancelling a paid charge, record its refund then credit. This distinguishes money returned from debt cancelled. A zero-value sale is `complimentary`, never evidence of cash received. Legacy licenses have no sales record until explicitly recorded. Default price changes do not rewrite sale snapshots. Renewals record a new sale; expiry changes remain a separate explicit licensing action. New issuance atomically records a plan-priced sale, unpaid unless a payment is supplied. Existing licenses without sales remain unchanged. No token payload, signing, activation or validation behavior changes. +A cash refund reopens the amount owed. A credit reduces the agreed charge; when cancelling a paid charge, record its refund then credit. This distinguishes money returned from debt cancelled. A zero-value sale is `complimentary`, never evidence of cash received. Every issued v1 license has a linked sales snapshot. Default price changes do not rewrite sale snapshots. Renewals record a new sale; expiry changes remain a separate explicit licensing action. New issuance atomically records a plan-priced sale, unpaid unless a payment is supplied. Existing licenses without sales remain unchanged. No token payload, signing, activation or validation behavior changes. New errors: `future_date`, `initial_sale_exists`, `unknown_sale`, `invalid_date`, `overpayment`, `reason_required`, `excess_credit`, `invalid_refund`, `invalid_payment_reference`, `historical_overpayment`. These are validation errors (400), except unknown sale (404) and initial sale conflict (409). @@ -322,7 +319,7 @@ Notifications are a **pull-based current-attention feed**, not background email Dependent apps must poll the authenticated notification endpoint, render unread items, open the existing support thread API, and POST read markers. Preference controls can use the corresponding PUT endpoint. Replying continues through the existing installation support message endpoint. Preinstallation requests now use a private status page; no email transport is introduced. A saved operator reply is available for retrieval; it is not falsely labelled delivered or read. -All new data uses versioned `platform_records` kinds (`price`, `sale`, `financial_entry`, `conversation_read`, `notification_read`, `notification_preferences`) and existing SQLite/Postgres transaction locking. No SQL schema migration or rewrite of legacy licenses/tickets is required. Existing full database backups include these records. Very large datasets may later warrant indexed relational tables; current list views are paginated and exports include all filtered records. +All new data uses versioned `platform_records` kinds (`price`, `sale`, `financial_entry`, `conversation_read`, `notification_read`, `notification_preferences`) and existing SQLite/Postgres transaction locking. The fresh v1 schema does not import previous development databases. Existing full database backups include these records. Very large datasets may later warrant indexed relational tables; current list views are paginated and exports include all filtered records. ## Notification history and bulk controls diff --git a/docs/backups.md b/docs/backups.md index 865705a..43e6717 100644 --- a/docs/backups.md +++ b/docs/backups.md @@ -2,7 +2,7 @@ Admins use **Settings → Backups** to configure destinations, enable independent daily UTC schedules, run **Back up now**, download archives, inspect upload results and verify recovery. All features are available in the self-hosted open-source server. The backup API and console require admin access. -On Vercel, follow [the serverless deployment and recovery guide](vercel.md). That mode requires durable remote archives, supports private Vercel Blob configured in Settings (or imported once from `BLOB_READ_WRITE_TOKEN`), uses portable PostgreSQL archives, and replaces the continuous worker with authenticated hosted invocations. Local-only/custom upload-only destinations are disabled. Native-tool and persistent-directory instructions below describe persistent-server mode. +On Vercel, follow [the serverless deployment and recovery guide](vercel.md). That mode requires durable remote archives, supports private Vercel Blob configured in Settings, uses portable PostgreSQL archives, and replaces the continuous worker with authenticated hosted invocations. Local-only/custom upload-only destinations are disabled. Native-tool and persistent-directory instructions below describe persistent-server mode. ## Bring your own storage (BYOC) @@ -10,11 +10,11 @@ Use **Configure storage** in Settings → Backups for private Vercel Blob, S3, R Provider settings are stored as versioned, authenticated ciphertext in the application database, using the external **data** keyring. Jobs pin the credential version used when created; later edits do not retarget older archives. Retain old credentials at the provider while those archives are needed. The **backup** keyring still encrypts archives and remains external, as do signing/authentication keys. Application reset retains storage configuration so its verified backup remains accessible. Keep independent recovery copies of provider credentials and keyrings: a completely unavailable database cannot supply its own recovery credentials. -On upgrade, explicitly configured environment credentials are imported once, without overwriting existing database settings. This requires a data keyring. After an upload/download/delete test and archive verification, remove the imported variables and redeploy; future changes need no restart. Automatic cloud credential chains remain a compatibility fallback, and are not copied from temporary instance roles into the database. +Configure provider credentials in Settings for durable, versioned jobs. Deployment-supplied provider defaults remain optional for operators using managed instance credentials; they are not imported into database settings. Jobs using those defaults depend on the deployment retaining them. There is no conversion of old jobs or schedules. -Custom upload endpoints accept an optional complete authorization header value directly; it is encrypted in the database too. Legacy environment references remain accepted for existing installations. These endpoints still require a persistent local archive and are unavailable on ephemeral Vercel hosts. +Custom upload endpoints accept a complete authorization header value, encrypted in the database. They require a persistent local archive and are unavailable on ephemeral Vercel hosts. -## Legacy environment imports +## Optional deployment defaults | Destination | Server configuration | | --- | --- | diff --git a/docs/cli.md b/docs/cli.md index c125e64..69c241f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -80,13 +80,12 @@ Restore current archives using `vp run backup:restore --file --job `. Credential examples must be generated locally, not copied from documentation. No cookies or administrator token belong in device requests. Public health, metadata, key discovery and request catalog need no authentication. Installation and private-request credentials are distinct. URL-encode path IDs; reject redirects when sending credentials. There are no undocumented query parameters required for these client routes. +One import account has one active installation. Seat release retains ownership; transferring it requires approved recovery. Recognition matches never free or transfer a seat. Whole-state rollback on a hostile OS, physical attestation and exact reconstruction of completely lost local history remain unsupported. -Unless stated otherwise, object inputs are strict: unknown fields cause `400 bad_request`; omission is allowed only for optional/defaulted fields; explicit null, zero, false, arrays and objects are invalid for string fields. Empty strings are allowed only where noted. Successful bodies are JSON **objects**, never bare grants or arrays. Normal success is 200; support/request submission is 202; administrator issuance is 201, including replay. Framework/proxy errors may have different envelopes or non-JSON bodies; do not authorize from them. +## Studio Proof responsibilities and testing -| Shared type | Actual rule | -| --- | --- | -| Installation ID in HTTP bodies | Trimmed string, 1–200 characters; **UUID is recommended, not required by the server**. SDK stored identities are UUIDs. | -| Request IDs | Zod `string().uuid()`; generate a standard random UUID v4 and persist it before sending. No undocumented ULID assumption. | -| Product ID | Configured lowercase slug, creation pattern `^[a-z][a-z0-9-]{0,59}$`. Unknown route product gives 404. | -| Credential | Generate 32 cryptographically random bytes, encode unpadded base64url (43 characters). Registration accepts `[A-Za-z0-9_-]{43,128}`; padding/whitespace rejected. The server cannot measure entropy and does not prove canonical base64 decoding. | -| Product key | Nonempty string; lookup hashes its trimmed uppercase form. Never log the input or response key. | -| Timestamp | Token/administrative timestamps use Zod offset-aware datetime validation; server emits UTC ISO strings. Support submission additionally normalizes its ISO timestamp with seconds and timezone to UTC. | -| Entitlements | Map, keys 1–64 characters, values finite JSON numbers, strings or booleans. No nulls, arrays or nested objects. Zero/false/empty-string values are structurally permitted, not implied authorization. | +The official SDK is JS/TS, with optional Windows/Linux/macOS collectors. There is no native Rust SDK; a Rust integration implements HTTPS, canonical Ed25519 verification, secure storage, receipt atomicity and recovery using the same public contract. Do not add Node just to call REST. -## Core lifecycle (CORE-1–7) +Test activation, offline restart/deadline, trial conversion, renewal replay, quota exhaustion, concurrent processes, negative grants, wrong product/installation/key, corruption, approved/rejected/expired recovery and interrupted persistence. Keep secrets and authorization/usage state out of ordinary configuration exports. Unsigned discovery/serverTime is not permission to extend authorization. -Here `P=/v1/products/default`. All five POST routes require a registered installation credential. Nothing registers implicitly on the server; SDK `activate`/`validate` first perform explicit registration for you. +## Public references -| Operation | Canonical body | Success and effects | -| --- | --- | --- | -| `POST P/register` | `installationId`, UUID `requestId`; optional metadata below | `{ok:true,recorded:boolean,serverTime,trial:null|{token:string}}`; pins credential and stores replay/trial decision atomically. | -| `POST P/validate` | `installationId,key` | `{valid:true,...safeLicense,seatsInUse,seatsAvailable,serverTime}`; **no seat and no signed offline grant**. A full license can still validate with `seatsAvailable:0`. Invalid/negative keys return errors, not `{valid:false}`. | -| `POST P/activate` | `installationId,key` | `{token,serverTime}`; binds this license/installation once. A repeated active binding succeeds even when all seats are occupied. | -| `POST P/refresh` | `installationId,licenseId` | `{token,serverTime}` for a retained active binding; token status can be **revoked/expired with HTTP 200**. | -| `POST P/deactivate` | `installationId,licenseId` | `{ok:true,serverTime}`; releases the selected license's binding. Existing license plus already released/missing binding also succeeds. | +- [Full import and recovery rules](import-licensing.md) +- [V1 token verification](verify-token.md) +- [Client OpenAPI](client-openapi.json) +- [Portable current test vectors](examples/client-contract/README.md) +- [Device recognition](device-identity.md) +- [SDK guide](../packages/keyforge-client/README.md) -Runtime accepts optional `key` and `licenseId` on **all four** post-registration operations, with key taking precedence. Missing both yields `unknown_license`; activation still requires a key. Use the canonical bodies above rather than depending on this permissiveness. `maxTokenVersion`, arbitrary issuance terms and other extras are rejected. Old unscoped routes are **unsupported**, with no negotiated compatibility window; do not treat SPA HTML fallback as an API response. - -Activation decisions are serialized across processes using database transactions/workspace locks. Retries of the same key/installation reuse one binding; the minted token/time can change. Concurrent distinct licenses can each bind the same installation: **replacement does not release the old license**. `installation.licenseId` is a convenience pointer, not an exclusive binding. Explicitly release the old seat when appropriate. A failed replacement attempt leaves the existing SDK grant unchanged; do not discard it solely because an unrelated candidate key failed. Successful replacement must verify and atomically persist before use, and must not reset local usage. - -| Refresh outcome | Result / cached authorization | -| --- | --- | -| Active bound license | Verify and save new grant; enforce signed expiry and grace. | -| Expired or revoked, binding retained | 200 signed negative token; **persist and supersede old active grant**. | -| Released or never bound | 403 `not_activated_here`; SDK saves denial and blocks fallback. | -| Unknown license/product | 404 `unknown_license` / `unknown_product`; SDK refresh saves denial. | -| Archived product | 410 `product_archived`; SDK refresh saves denial. | -| Another license activated | Old binding remains refreshable until separately released/revoked/expired. There is no signed “replaced” status. | - -After a lost deactivation response, retry the same identity/license. On success clear the local paid grant and deselect trial atomically; preserve usage. If the application cannot persist success, block protected mutations until reconciled. Do not create a new identity to repair an ambiguous operation. - -## Registration and recognition (AUTH-1–7, DEVICE-1–3) - -Optional registration fields: `appVersion` max50, `platform` max100, `userName`/`userEmail` max200, all strings defaulting to `""`. Empty permitted, null rejected; userEmail is not email-format validated here. Omit them for privacy. Registry-disabled operation still persists credential, replay and machine/trial security records, but returns `recorded:false` and suppresses registry metadata; this is **not** a grant or a registration failure. User name/email retention additionally requires `registry.collectUserInfo`. Trials depend on product policy and machine evidence, not the presence of contact details. `trial:null` means product trial disabled; a disabled registry does not disable registration or trials. - -Registration replay key is product + installation + request UUID. The fingerprint is SHA-256 of **JSON.stringify of the parsed/defaulted object**, not raw HTTP bytes. Top-level schema order/defaults are normalized; `machineSignals` is retained as supplied in the fingerprint, so array order and nested raw JSON differences can conflict. Missing metadata and explicit empty strings normalize alike. Credential must still match. Same ID/body returns the original complete response/token; changed parsed body gives 409 `idempotency_conflict`. Records have no automatic replay TTL. Preserve the original body even if version/user/hardware changes while pending. A new UUID is a new registration decision, not a safe way to renew a trial. - -The credential is stored as a hash scoped to product/installation. The SDK also pins deployment URL and subject kind in secure state, but the server credential itself is **not cryptographically bound to URL, hardware or subject kind**. Copying valid identity+credential+grant to another machine is not prevented by hardware hints. Credential rotation/recovery under the same identity is **unsupported**. Restore a trusted atomic secure-state backup, or have staff release old seats and register a new identity with explicit migration. Keep surviving local usage linked to the new remote identity. Do not silently erase corrupt/context-mismatched state or register a new identity after storage errors. - -`PUT P/installations/{installationId}/device-signals` requires that installation's credential. Strict body `{machineSignals:[{kind,hash}]}`; required array, max3, empty permitted; each item strict, kind one of `machine-guid`, `smbios-uuid`, `volume-serial`, `linux-machine-id`, `macos-platform-uuid`, `root-volume-uuid`, hash exactly 64 lowercase hex characters. Null/malformed values reject. Legacy registration is more permissive: any JSON `machineSignals` is accepted, valid pairs normalized, malformed/unknown entries dropped, identical kind/hash pairs deduplicated, with no server-side three-entry bound on that registration field. SDK limits it to three. Backfill stores the current list and unions normalized evidence into the authenticated identity's existing groups. It never matches/merges other groups; empty clears current availability while retaining history. Duplicate pairs are not a unique physical-device assertion. - -New native installations should implement the existing **v1 namespaced hash**, not a bespoke CPU fingerprint. [Device identity](device-identity.md#shared-v1-normalization-and-hashing) and [Windows vectors](windows-device-identity.md) give normalization/placeholders and reproducible Rust-compatible vectors. Hash compact UTF-8 JSON `["keyforge-device-signals",1,canonicalServerUrl,productId,kind,normalizedIdentifier]` with SHA-256. Canonicalization must match the documented URL serialization. Send no raw IDs. Permission failure/hardware replacement yields partial/empty diagnostics; continue installation-based operation. Preserve old credentials/usage, backfill rather than re-register. Cross-scheme or cross-origin recognition is not automatic. New-registration recognition needs two distinct matching kinds, discards overly common evidence and chooses the oldest best match without merging. Hints can influence trial recognition but never authenticate, transfer/free seats or reset usage. - -## Signed payloads and trust (TOKEN-1–6) - -Complete strict JSON Schemas are in the frozen package. All token fields are required, including nullable fields. License `subject` has only `kind:"installation"|"service"` and `id` (1–200 string); `subject.id` must equal `installationId` in addition to schema checks. Rust desktop clients must require `kind:"installation"` and match the expected ID/product/issuer. Trial has no subject object. `issuedTo` is an unbounded string in the token schema, not necessarily nonempty. Trial allowance is nullable nonnegative integer (Zod safe-integer range), independent of paid entitlements; `supportReference` is nullable string. Trial v2 has no `offlineGraceUntil`. - -Signature is Ed25519 over **decoded JSON UTF-8 bytes**, not the encoded segment or reserialized JSON. SDK requires exactly two canonical unpadded base64url segments, a 64-byte signature, valid UTF-8 and total token length at most65,536 characters. JSON Schema cannot encode signature verification or subject-ID equality. JSON numbers must be finite; native clients should reject quota numbers outside their agreed safe-integer policy. The current JS parser uses JSON.parse: **duplicate property names are last-wins, not explicitly rejected**. New server tokens never deliberately contain duplicate keys. A native verifier can reject duplicates conservatively; do not claim this rejection is already implemented in the SDK. - -Verification authenticates signature, strict shape, approved key, context and issuance time. It can return an authentic **negative** grant. Import authorization must additionally enforce status, expiry, offline deadline and Studio Proof's quota/integrity policy. Current SDK tolerance is 300,000ms for future issuance and rollback detection; it **never extends expiry/grace**. At equality with a deadline, authorization ends. Persist max(local time, signed issuedAt) and refuse when clock+tolerance is below prior maximum. Unsigned response `serverTime` must not advance trusted time. This is not a secure clock or resistance to a compromised host. - -Stage key → authenticate/distribute new public trust to clients → activate key → retain retired trust for previously signed grants. Discovery exposes optional `pending` plus `kid/publicKeyPem/retiredAt` and `serverTime`; it does not establish trust. There is no signed compromised-key feed or immediate offline revocation. Remove compromised trust via authenticated application update/policy with an explicit offline impact decision. Unknown kid means reconnect/update trust, never fetch-and-trust. Legacy token versions/missing context are **unsupported by the current SDK**. There is no built-in grandfather policy; any temporary legacy verifier requires an explicit separately reviewed migration policy. - -## Product-policy snapshot and unresolved quotas (QUOTA-1–7) - -Read-only production catalog snapshot at review time: - -| Definition / plan | Exact configured values | -| --- | --- | -| `imports` definition, revision2 | type `number`, default10000, minimum1000, maximum100000, active; description “this would limit the number of import a person could perform” | -| `standard`, revision2 | `{imports:20000}`, duration32 days, activationLimit1, active, price50000 santim = **ETB500.00** | -| `standard-plus`, revision1 | `{imports:50000}`, duration90 days, activationLimit1, active, price100000 santim = **ETB1,000.00** | - -These are plan templates, **not disclosure of customer license snapshots**. Existing licenses can have different copied terms. Definition defaults are not automatically injected into absent license entitlements. Bounds/type validation do not establish integral lifetime-count semantics: the number definition currently accepts fractional values within bounds. The server signs terms; it does not count Studio Proof imports or know photographs/videos/duplicates/deletions. A trial's 20,000 units are not intrinsically “imports” until application policy maps them. - -**QUOTA-2–6 require a product-owner decision.** Recommended contract for the stated requirement is a safe-integer **lifetime cumulative ceiling**, a monotonic local counter, both time and quota checks for hybrid grants, and explicit separately typed unlimited policy. Do not implement this recommendation as an already agreed server rule. Define the unit, successful-import commit point, duplicate/retry deduplication, deleted-media treatment, trial-to-paid accounting, top-ups and downgrade behavior first. Missing/wrongly typed/negative/fractional/out-of-range quota should fail closed for new imports; zero is exhausted, and missing must never silently mean unlimited. Existing catalog minimum1000 prohibits newly configured zero paid grants; generic token structure still permits zero. - -Illustrations **only if the owner adopts a lifetime ceiling**: at12,000 total usage, ceiling50,000 leaves38,000. Same-plan renewal or replacement with the same ceiling leaves38,000, not50,000. An extra10,000 purchase must explicitly raise signed ceiling to60,000, leaving48,000; reusing50,000 does not add units. Downgrade to10,000 blocks new imports without reducing12,000 used. Trial-to-paid retains12,000 and leaves38,000 against50,000. A hybrid expired grant blocks imports even with quota left. There is no current top-up/recovery endpoint that performs these application-specific decisions. - -No vendor metering credential or per-import network call is required by desktop activation. `/usage/summary` and `/usage/consume` use separate trusted-backend credentials; they cannot enforce purely local offline lifetime history. Never distribute these credentials. Paid authorization evaluation in the SDK returns entitlements without consuming them; SDK `trialConsumed` is a trial-only counter, **not** Studio Proof's complete lifetime ledger. - -## Trials, renewal and offline use (TRIAL-1–6, RENEW-1–4, OFFLINE-1–3) - -Fresh = first recognized registration or exactly one registration after a staff clear. Spent = previously recognized group; signed allowance is then0. Fresh allowance0 is exhausted; null allowance means no numeric trial cap, requiring the duration policy. Null expiry means no time deadline. Current quota-only fresh grant20,000/null-expiry has no paid-grace refresh deadline; enforce persisted local consumption. `trial:null` supplies no trial authorization. Never replace a signed spent/unusable trial with a local fresh trial. If neither paid grant nor explicitly selected usable trial exists, remain unlicensed for protected imports. - -Staff `POST /admin/products/default/machines/{machineId}/clear` records `clearedAtCount` and audit. Next **new request UUID** registration for that authenticated identity re-evaluates eligibility; old registration replay returns the old decision. Retain body/UUID for each pending registration until resolved. SDK `register()` short-circuits once registered and `startTrial()` replays its old body without adopting a new decision; **no SDK trial-reconsideration method is supplied**. A native implementation may perform a deliberate new registration after staff approval while preserving credentials and usage. Do not reset `trialConsumed` or the lifetime ledger. Staff clear is eligibility metadata, not a signed recovery grant. There is no dedicated durable signed registration witness when trials are disabled; do not repurpose an expiring/spent trial as a new witness protocol. - -The server does not define whether trial can resume after paid expiry/revocation/release. SDK keeps trial data across paid use, prioritizes any paid token even when negative/expired, and deactivation clears paid state and deselects trial. An explicit later startTrial can select the retained still-usable trial; there is no irrevocable paid-conversion lockout. **Choose and implement Studio Proof's resume policy explicitly** without reducing usage. - -Renewal alternatives: admin PATCH existing license expiry/entitlements with optimistic revision, then refresh its binding; or issue a new plan-based license, deliver/activate the new key, and explicitly release old binding. Plan edits do not rewrite licenses or immutable sales. PATCH changes neither plan ID nor payment history and does not automatically record a renewal sale; use the separate staff finance renewal workflow if appropriate. A new license creates its own initial sale. Refresh cannot discover a replacement ID; use delivery/explicit key entry. - -**Signed revision and total ordering are unsupported in v4.** Administrative record `revision` is not signed. SDK rejects earlier issuedAt for the same license and equal-time active grants replacing a cached negative grant. Equal timestamps with different active terms, concurrent processes, different license IDs and re-presented old keys are not totally ordered. Serialize native operations, correlate requests and persist negative outcomes, but do not claim these solve cryptographic rollback. If that guarantee is required, agree a versioned signed revision protocol and migrate verifiers before use; do not add bespoke fields to strict v4 or overload generic entitlements as a security revision. - -Refresh well before grace expires, at app startup/resume and periodically online (for example daily with jitter). Scheduling is application policy; the SDK has no background scheduler. Use earlier of expiry and offlineGraceUntil for paid licenses, with no extension on network error. Offline revocation is only learned on reconnection or expiry of cached bounds. After clock correction, re-evaluate retained time/deadlines; never roll back a persisted clock maximum just to restore access. Server backup restoration can resurrect historical policy/bindings; client durable negative/high-water state must survive, and there is no signed restore epoch. - -## Recovery and migration (RECOVERY-1–5, MIGRATION-1–6) - -**Recovery-specific signed usage authorization, one-time recovery adoption, signed usage baselines and a signed recovery/reference/revision schema are unsupported.** No existing endpoint can issue such an artifact. Ordinary license keys authorize normal activation only, never bypass local usage-integrity checks. A machine support reference is a lookup aid, not a secret or recovery credential. Explicit contract decision for the current version: reject bespoke recovery claims in v4 and keep usage recovery fail-closed. A separate versioned signed artifact is a future design requiring owner approval and server/client work; it is not delivered by this documentation change. - -| Local migration/recovery situation | Supported safe handling / remaining boundary | -| --- | --- | -| One verified history survives | Preserve it and its maximum count; repair redundant storage atomically under Studio Proof's own integrity rules. | -| Histories conflict | Preserve evidence and any known high-water mark; block new imports pending explicit local/staff reconciliation. Do not choose the smallest count. | -| Credentials survive, usage unverifiable | Existing license does not authorize resetting history. Keep read access; import recovery is unresolved/unsupported. | -| Usage survives, credentials lost | Preserve local usage identity; staff releases old seats; register new remote identity and explicitly associate it with surviving usage. No hardware-based credential recovery. | -| Nothing trustworthy survives | No supported zero-reset or signed baseline-recovery shortcut. Requires a new approved recovery protocol/policy. | -| Old ID has no credential | It cannot be claimed: register a new ID/credential and use operator-assisted release/reissue. Preserve old usage separately. | -| Historical key available | Re-enter it through registered product-scoped activation, subject to current status/seats. Hash-only originals cannot be reconstructed. | -| Original unavailable | Admin may reveal an encrypted retained original, or explicitly reissue; reissue has financial side effects and needs staff decision. | - -Production has aggregate catalog usage, but the generic product contains no trustworthy Studio Proof provenance tag. **MIGRATION-3 cannot be answered by naming all `default` records as Studio Proof**. Have the owner reconcile provenance privately; no customer rows were exported for this review. Hardware replacement alone does not require a new identity if secure state survives. Reinstall/app-data loss/restore must not erase known usage or silently claim a new trial. Stage migration with atomic checkpoints, retain trusted original state for rollback, verify token formats before switching, and never downgrade to a client that ignores negative grants/new identities or resets usage. Legacy offline grandfathering and protected-usage recovery remain explicit blockers, not implemented guarantees. - -## Support transport (SUPPORT-1–6) - -Let `I=P/installations/{installationId}`. All routes require that installation's credential. No filters or pagination apply to customer GET collections; records are ordered by record ID, not guaranteed chronologically. Paginate/render locally and sort by timestamps if needed. - -| Route | Input / output | -| --- | --- | -| `POST I/support` | Strict `requestId` UUID, `licenseId` string validated after trim (1–200; send it without surrounding whitespace because lookup uses the original value), category, urgency, subject, message, createdAt; optional strict `reporter:{name?}` and `app:{version?,platform?}`. Require `Idempotency-Key` **equal to requestId**. Returns202 `{request:{id,clientRequestId,status,receivedAt},serverTime}`. | -| `GET I/support` | `{requests:[Thread...]}`; this installation only. | -| `GET I/support/{id}` | `{request:Thread}`; nonexistent/other-installation ticket gives404 `unknown_support_request`. | -| `POST I/support/{id}/messages` | Strict UUID `requestId`, integer current `revision`, trimmed message1–5000, optional `internal:false` defaultfalse. `true` rejected for customers. Returns `{request:Thread}`. No Idempotency-Key header is required here. | - -Category enum: `technical`, `licensing`, `billing`, `feature_request`, `other`. Urgency: `low`, `normal`, `high`, `critical`. Trimmed initial subject3–120 and message10–5000; initial createdAt must have ISO date, seconds and `Z`/offset (fraction optional1–9 digits) and parse as a date; normalized UTC persisted. Optional reporter.name max200, app.version max50/platform max100 before trimming; missing/empty/whitespace → null, explicit null rejected. Subject/message/createdAt required, no null. Input schema plus normalization both apply. - -Thread fields: `id`, integer `revision`, licenseId, installationId, clientRequestId, category, urgency, subject, original message, nullable reporterName/appVersion/platform, clientCreatedAt/receivedAt/updatedAt, status, messages array. States `queued|in_progress|waiting_customer|resolved|closed`. Each public message `{id,author:"customer"|"support",body,internal:false,createdAt}`; array is insertion order. Assignment is omitted, internal notes filtered, operator author identities mapped to support. Current new ticket IDs are `srv_`+UUID; historical imports may differ (old helper produced prefixed ULID). **Treat IDs as opaque**. - -Creation requires an existing license **and binding** for the installation. Technical/feature_request/other additionally require active license and unreleased binding. Licensing/billing accept expired/revoked/released bindings. Trial-only/never-bound installations cannot create tickets; customer GET/reply authenticate ownership without rechecking license status. An unbound user needs the operator's separately advertised external contact; KeyForge supplies no public help/email endpoint. - -Creation replay compares normalized category/urgency/subject/message/reporter/app/timestamp scoped to license+installation+requestId. Changed body gives409 `idempotency_conflict`. **Eligibility is checked before replay**, so a later binding/status change can prevent replay acknowledgment; retain outbox and reconcile, do not rewrite the queued identity to a new license silently. Reply replay finds requestId among messages; same body/internal/author returns current thread before stale revision/closed checks. New replies require current revision (409 `revision_conflict`); GET/reconcile before a deliberate revised retry. Closed gives409 `request_closed`; resolved/waiting_customer customer replies reopen queued. Durable outbox must retain original identity/body and distinguish acceptance from staff response. - -Configured creation quota defaults10 per hour per license+installation based on stored tickets, 429 `support_rate_limited`; this domain error does not supply Retry-After. No automatic ticket TTL, attachments, email delivery or realtime transport. Poll for retrieval with bounded backoff. HTTP202 means stored, not delivered to a staff email or resolved. - -## Notifications, delivery and preinstallation requests (DELIVERY-1–5) - -| Route | Strict body / success | -| --- | --- | -| `GET I/notifications` | No body/filter/pagination; `{items:[Notification...],preferences:{messages:boolean,expirations:boolean}}`. Both defaults true. | -| `PUT I/notifications/preferences` | Required booleans messages/expirations (false accepted, null/omission rejected); `{ok:true}`. License deliveries always included. | -| `POST I/notifications/read` | `eventId` string1–500, `{ok:true}`. Repeated marker idempotent in effect, updates timestamp. | -| `POST I/licenses/{id}/collect` | No required JSON body; `{key:string,license:{id,plan,issuedTo,expiresAt:string|null}}`; no seat consumed and no automatic activation. | - -Notification required fields: `id,kind,title,date,read`. Reply adds ticketId; expiration adds licenseId; license delivery adds licenseId/deliveryStatus/collectPath. kind is `reply|expiration|license`; deliveryStatus `available|retrieved|manual|failed`. IDs: `reply:`, `license:`, expiry includes licenseId+expiry+threshold1/7/30. Feed contains latest public staff reply **per thread**, deliveries, and current pointed license's upcoming expiration. It is not an immutable event archive; notices can disappear as source state changes. No fixed retention/polling/collection-size guarantee; read markers have no TTL. No stable chronological feed order promised. - -Collection requires exact targeted installation; wrong recipient/missing delivery404 `delivery_unavailable`, inactive/missing license409 `delivery_unavailable`, decryption failure503 `delivery_failed`. Repeat collection returns same retained key and preserves first retrievedAt; fixes can permit retry of failed delivery. Prefer explicit user confirmation before replacing an existing key/grant; collection alone is not consent to replace. Server builds a relative same-origin collectPath. Native clients should construct the path from validated IDs (as SDK does), never forward credentials to an arbitrary supplied URL. - -Preinstallation requests are enabled in this deployment (public catalog verified). They use a **different** 32-byte random secret, same accepted43–128 base64url character pattern, and a private status URL fragment; send that secret as Bearer, never querystring or installation credential. - -| Route | Contract | -| --- | --- | -| `GET /v1/license-request-catalog` | No auth/query; `{products:[{id,name,plans:[{id,description}]}]}`; active products/plans only. | -| `POST /v1/license-requests` | Strict UUID id, productId string1–60, plan1–200, trimmed name1–100, email valid format/max254. All required, null/zero/false invalid. Secret Bearer; no Idempotency-Key header required.202 `{id,status:"requested"}`, even replay after issuance; GET status for current state. | -| `GET /v1/license-requests/{id}` | Secret Bearer; `{request:{id,productId,plan,name,email,status,licenseId,createdAt,revision},delivery:Delivery|null}`. Status requested/issued; licenseId nullable. Secret hash omitted. | -| `POST /v1/license-requests/{id}/collect` | Same secret, no required body;409 `request_pending` before issuance, otherwise collected model above. | - -Delivery includes id/licenseId, nullable installationId/requestId, status, createdAt, nullable retrievedAt/error, and persisted revision where read from store. Exact ID/body/secret replay works; changes409 `idempotency_conflict`. Invalid/missing secret404 `request_not_found` on retrieval,400 `invalid_request_secret` on submission. Inactive/unknown plan400 `unknown_plan`, unknown product404. Submission limit10/hour/IP. No request-secret expiration, rotation, recovery or email status delivery; preserve private link securely. Contact data remains server-side for staff handling; do not log/share fragments or embed the private request object in analytics. - -## Staff-only contract (ADMIN-1–6) - -Never ship this domain in Studio Proof desktop. Staff automation may use configured admin Bearer (actor `cli`); console sessions use Better Auth cookies. Admin-token absence disables bearer administration. Invalid credential401 `admin_unauthorized`, disabled administration503 `admin_disabled`. Session non-GET writes reject an explicitly different Origin (403 `invalid_origin`); omission is accepted, not a mandatory Origin header. Financial/license mutations and key reveal require admin; support can read **safe** catalogs/licenses/activations and release seats/clear trials. All operators belong to one workspace, without per-product membership isolation. - -Here `A=/admin/products/default`: - -| Endpoint | Exact applicable contract | -| --- | --- | -| `GET A/catalog` | Product, definitions, plans, planUsage, usage, licensesScanned, keyStorageEnabled. Prices in plans exposed to admins only. Definition/plan types are in pinned platform source; snapshot above lists actual terms. | -| `GET A/licenses` | Optional status filter raw equality; `q` case-insensitive JSON substring, limit default25 clamped1–200, offset default0. Use nonnegative integers. `{licenses:[safeLicense],total,counts:{active,expired,revoked}}`; counts precede filters. Order by record ID, not issuance date. | -| `GET A/licenses/{id}` | `{license:safeLicense}`,404 unknown_license. | -| `POST A/licenses` | See issuance below; admin only,201 license/key/sale/delivery. | -| `PATCH A/licenses/{id}` | Strict required integer revision; optional issuedTo string/max200 (empty permitted), entitlements map, activationLimit positive integer, expiresAt datetime or null. Omitted terms unchanged; null expiry perpetual.409 revision_conflict,400 bad_entitlements; `{license:safeLicense}`. No plan/status/price override. | -| `POST A/licenses/{id}/revoke` | No parsed body required, admin only; `{ok:true}`. Repeated revocation updates timestamp/revision/audit; current tokens remain valid offline until bounded. | -| `GET A/licenses/{id}/activations` | `{activations:[{id,licenseId,installationId,activatedAt,deactivatedAt,revision,machineGroups,signalKinds,signalAvailability}]}`. Released deactivatedAt is string, active null. No pagination; a nonexistent license can yield empty array. | -| `POST A/licenses/{id}/deactivate` | Required `{installationId}` trimmed1–200; this handler ignores other body properties rather than parsing a strict object. Support/admin allowed. `{ok:true}`, repeated or missing binding succeeds. Does not create license or sale. | -| `POST A/licenses/{id}/key` | Admin only; session requires **`password`**, bearer CLI bypasses password. Optional `key` string lets staff store a supplied matching historical original (normalized key hash). Returns `{key}`, no-store.5/min/IP; audit reveal/store.400 key_mismatch;404 original_key_unavailable/unknown_license;401 reauthentication_required; encryption/provider failures503 or500. Hash-only originals cannot be reversed. | - -Safe license fields: id, productId, keyReference (masked), plan, entitlements, issuedTo, stored status active/revoked, subjectKind installation/service, issuedAt, nullable revokedAt/expiresAt, activationLimit, revision, keyStored boolean. Original key/hash/encryptedKey are excluded. Stored status is **not always derived expiry status**; compare expiresAt or use signed refresh. Validation adds seatsInUse/seatsAvailable/serverTime to this representation. Machine groups expose `{id,supportReference}` and kinds/availability only, not hashes. - -Issuance strict body: required UUID requestId and trimmed plan1–200; optional integer planRevision, expectedPrice integer0–100,000,000,000 santim; issuedTo trimmed/max200/defaultempty; installationId trimmed1–200 or licenseRequestId UUID, mutually exclusive; subjectKind installation/service defaultinstallation; optional strict payment `{date:"YYYY-MM-DD",method:trimmed1–100,reference:trimmed1–200}`. No null alternatives except absent payment. Recipient/name required after resolution; registered installation requires credentialHash; request must be unissued and same product. Current active plan terms and resolved ETB price copied; optional revisions/prices should be supplied to catch stale quotes. Direct allowance/expiry/seat overrides at issuance are unsupported; configure a plan or explicitly PATCH later. - -Issuance atomically creates license, one immutable-price **initial** sale (id=requestId), optional full payment, delivery and request linkage/replay marker. No payment→unpaid, zero price→complimentary. Initial payment date must equal today's UTC sale date; zero-price payment rejected. No financial records were created by this review. Same parsed body/requestId/actor returns same license/key/sale (201); differing actor/body409 idempotency_conflict. Current sale balances/delivery may evolve on replay. Plan edits do not change historical sale amounts. Audits are appended **after** domain transactions: audit failure can return500 after commit; replay/reconcile rather than issue with a new UUID. Mutation+audit is not one atomic guarantee. - -Trial intervention uses the machine clear route above (support/admin), audit `machine.clear`; seat release audit `license.deactivate`; issuance/update/revoke/key reveal similarly audited when deployment audit feature enabled. There is **no recovery-authorization issuance endpoint**, no mandatory staff comment/reference on trial clear, and no transactionally guaranteed audit outbox. A requirement for immutable referenced recovery approvals needs server changes, not an invented CLI command. - -## Errors, retries and local state (TRANSPORT-1–4) - -Normal domain envelope `{error:string,message:string,retryable:boolean,serverTime:string}`. Framework rate errors may additionally include statusCode/retryAfter. SDK uses status/code, not untrusted text; its retryable status set is429/502/503/504 plus network/timeouts. A generic500 is not automatically retried. Retain idempotency keys across manual retry. - -| HTTP / code | Recommended action / cached-state effect | -| --- | --- | -| 400 bad_request, bad_credential, key_required, invalid_support_request, invalid_request_secret, unknown_plan, bad_entitlements, price_required, invalid_payment/date/recipient/customer | Correct input/policy; do not loop. Unrelated failed candidate activation preserves prior valid grant. | -| 401 installation_unauthorized | Stop credential use, restore/reconcile identity; SDK refresh persists denial. Never auto-register a new identity to evade it. | -| 403 not_activated_here | Binding absent/released; block cached paid grant; explicit activation/staff release as appropriate. | -| 404 unknown_key | Candidate key invalid; no seat, preserve unrelated prior authorization. | -| 404 unknown_license / unknown_product;410 product_archived | Refresh denial blocks cached fallback; operator correction needed. | -| 410 license_expired / license_revoked | Validate/activate reject; do not adopt candidate. A corresponding refresh instead supplies signed negative200; persist it. | -| 409 activation_limit_reached | No seat available; staff/user releases another binding, retry activation without changing identity. | -| 409 idempotency_conflict | Stop, restore original durable body or deliberate new operation; never blindly change request UUID. | -| 409 revision_conflict | GET latest; reconcile, then deliberately submit revised update. Same pending message replay remains unchanged. | -| 403 paid_license_required;404 unknown_support_request;409 request_closed | Support unavailable/ownership/status issue; preserve queued identity/history and obtain staff help. | -| 429 rate_limited | Honor Retry-After seconds (HTTP client may also understand date); bounded backoff+jitter. Plugin emits header. | -| 429 support_rate_limited | Same durable request later; no Retry-After header supplied by this domain error. | -| 503 signing_unavailable / encryption_unconfigured / encryption_key_missing;500 internal_error | Do not unlock or rotate identity. Reconcile mutations; operator repairs provider. Signing/decryption can fail after other operations already committed. | -| 409 delivery_unavailable / request_pending;404 delivery_unavailable / request_not_found;503 delivery_failed | Reconcile exact recipient/private secret and status; retry transient failed decryption only after operator repair. No new activation implied. | -| Network/timeout/502–504 | Ambiguous mutation: reuse original body/identity/UUID. Previously valid cached grant can remain only within all signed/local limits. | -| Malformed JSON, wrong signature/context/schema, unknown kid | Never adopt response or unlock from it. SDK retains prior grant; it can remain within its old limits unless a persisted verified denial blocks it. No automatic trust discovery. | - -No server-enforced minimum TLS version, HTTP-client timeout or redirect behavior is declared by this app; hosting terminates HTTPS. SDK enforces HTTPS except loopback, rejects redirects, timeout10s/attempt, default3 attempts (configurable1–5), bounded exponential backoff/jitter and Retry-After. JSON body cap is Fastify default1MiB; host may reject sooner. There is no explicit maximum customer response collection or total response byte limit in the SDK. No compression contract guaranteed. Rate defaults: registration/backfill30/min, validate20/min, activate20/min when limits enabled; refresh/deactivate no route quota. In-memory plugin counters are not global across serverless instances; ticket quota uses stored records. Do not advertise globally uniform throttling or an SLA. Use conservative polling and own response/output bounds. - -Secure storage must atomically persist credentials, exact pending body, grants, denial, clock high-water and usage state. SDK serialization is per client instance, **not cross-process storage locking**. Implement durable locking/transactions in Rust/storage adapter; a failed save must not be interpreted as fresh state. After remote success/local save failure block mutations and retry/reconcile original operation. Registration replays original token, activation re-mints same binding, deactivation repeats safely, support uses durable IDs/revision, issuance reuses request UUID. - -Never log Bearer/cookies, keys, token bodies, raw device IDs/hashes, private-link fragments, customer names/emails or entire request/response objects. Zod error messages can contain field paths, allowed values or unrecognized field **names**; framework parse/proxy messages may echo malformed input. Do not rely on every message being safely redacted; display controlled code-specific copy and redact transport logs. Fastify request logger is disabled here, but host/app logs need their own policy. - -## State transitions and staging acceptance - -| Trigger | Required transition | -| --- | --- | -| Fresh launch / skip | Persist identity before networking; unlicensed; never silently grant paid/trial access. | -| Registration | Retain signed trial decision but do not select it automatically. | -| Explicit trial | Require online onboarding; verify fresh/time/allowance; select only if application policy allows; preserve consumption. | -| Valid activation | Verify context/signature/terms; atomic save; licensed within time/quota/integrity limits. | -| Failed replacement | Keep unrelated prior valid grant and usage; report failure. | -| Refresh / signed negative | Persist new grant, including expired/revoked, before returning; block old-grant fallback on denial. | -| Release | Reconcile remote release; clear paid grant and deselect trial, retaining usage. | -| Deadline / exhausted quota / corrupt usage | Block new imports; Studio Proof keeps existing content readable. | -| Recovery / legacy state | Fail closed for protected mutations until supported migration or separately approved recovery exists. | - -The [offline fixture file](examples/studio-proof-contract/fixtures.json) includes a clearly test-only public key, fixed clock/context, decoded payloads, signed tokens, expected verifier results and SDK authorization states. It contains **no private key or production license**. Valid active/expired/revoked, trial fresh/spent/zero/null, null expiry, quota-only/time-only/hybrid examples and context/schema/signature/key failures are exercised by `test/integration-contract.test.ts`. Fixture entitlement labels illustrate structure, not an approved Studio Proof quota policy. Unsupported signed revision/recovery cases are explicitly listed as gaps, not fabricated success fixtures. - -For transport scenarios, existing `test/client-sdk.test.ts` exercises lost registration response/restart, duplicate activation, exhausted seats, disabled/spent trials, signed negative refresh, clock rollback, storage/context corruption, support retries/privacy, collection, native signal backfill and unchanged counters/finances. Use those tests against a **temporary isolated server/database with generated test keys**, not production. Before a hosted staging drill: confirm separate database/verification database, independent signing/auth/encryption keys, demo accounts and storage; never copy production keys/customer data. With owner-authorized disposable staging records, configure priced test plans, issue/revoke/release, inject response loss/races, and verify sales/payments were not duplicated. Staging issuance necessarily creates demo financial records; this questionnaire does not authorize production fixtures. Clean only isolated test records. Run `vp run check`, `vp run test`, `vp run build:all` and browser/package checks. Native Rust acceptance must independently prove monotonic usage, cross-process locking and retention through migration; passing SDK fixtures is insufficient for these app-local guarantees. - -## Handoff decision list - -- **Supported now:** scoped registration/activation/refresh/deactivation, pinned v4/v2 verification, bounded paid offline use, optional OS signals/backfill, seat limits, plan snapshots/manual ETB finance, installation support/polling/delivery and private preinstallation requests. -- **KeyForge configuration:** confirm product ownership; configure active/priced plans, numeric bounds, trial/time/offline policy, trust distribution and isolated staging. Current values above are a dated snapshot, not permanent policy. -- **Studio Proof implementation:** Rust verifier, secure atomic/locked persistence, explicit onboarding, import entitlement enforcement, monotonic lifetime ledger, safe retry/outbox, grant/denial handling, no-reset migration, consent to replace keys and preserving read access. -- **Product-owner decisions:** imports counting unit/ceiling/top-ups/unlimited semantics, trial-to-paid/resume, grandfathering, historical-record provenance and no-history recovery. These block a complete business integration; neither “confirmed” deployment values nor SDK state names settle them. -- **Unsupported / changes required:** credential rotation/recovery, signed total ordering/revision, signed registration witness without trials, signed protected-usage recovery and one-time adoption, physical-device attestation, immediate offline revocation, customer email/attachments/realtime, fully enumerated privileged OpenAPI and transactionally guaranteed audit. Do not invent endpoints or add incompatible claims to v4. +An end-to-end acceptance run on an explicitly configured test product is required before any Production rollout. This contract includes no customer email/realtime transport, multi-device shared offline import budget, signing-secret distribution or automatic state conversion. diff --git a/docs/ui-component-inventory.md b/docs/ui-component-inventory.md index 26b3ea7..947e6e3 100644 --- a/docs/ui-component-inventory.md +++ b/docs/ui-component-inventory.md @@ -45,7 +45,7 @@ Use Choice rather than choosing Select/Combobox independently on each page. Use Registry inspection: `.artifacts/communication-registry.json`; upstream sources inspected via shadcn CLI after Shoogle discovery. The established console identity is preserved. Financial editing stays in dialogs, except report filters and notification preferences which act on the current view. Public replies and internal notes have separate history modes and clearly labelled composers. -Inbox previews explicitly prefix internal messages with “Private note”. Notifications now retain personal product-scoped history, deletion tombstones and compatibility for legacy read state. Operator preferences cover messages, assignments and expiration alerts at 30, 7 and 1 days; disabling them stops future captures without erasing history. Preferences live in Settings and persist on the server, while appearance is local to this browser. The page and dropdown refresh every 30 seconds while mounted; read actions and dependent-app preferences use authenticated APIs. Recording an initial or renewal sale does not change license access or expiry. +Inbox previews explicitly prefix internal messages with “Private note”. Notifications now retain personal product-scoped history, deletion tombstones. Operator preferences cover messages, assignments and expiration alerts at 30, 7 and 1 days; disabling them stops future captures without erasing history. Preferences live in Settings and persist on the server, while appearance is local to this browser. The page and dropdown refresh every 30 seconds while mounted; read actions and dependent-app preferences use authenticated APIs. Recording an initial or renewal sale does not change license access or expiry. The final desktop/mobile browser pass covers Settings tabs, avatar workflow, persistent appearance, notification dropdown/table/bulk controls and confirmation, including dark appearance and overflow checks. Evidence is under `.artifacts/console-smoke/`; see `ui/.impeccable/surfaces/console-patterns.md` for the capture list. Both reduced-motion animation and transition duration use `0ms` in `ui/src/index.css`, verified by live computed colors after the dark contrast correction. No shipping raster was added. Shared SQLite setup and demo reset guidance is in `docs/database.md`. diff --git a/docs/verify-token.md b/docs/verify-token.md index bb900ec..b07fbd6 100644 --- a/docs/verify-token.md +++ b/docs/verify-token.md @@ -1,47 +1,27 @@ -# Signed token verification +# Verify KeyForge v1 authorization -Opt-in import licensing adds strict license v5, trial v3 and a **separate** recovery v1 schema. See [import licensing](import-licensing.md) and the current OpenAPI for fields, revision/generation checks and migration rules. V4/v2 remain supported unchanged. Recovery artifacts must never be accepted as application licenses. +License, trial and usage-recovery artifacts all use `v: 1`, distinguished by `kind`. This is the sole supported protocol. There is no version negotiation, alternate parser or data-adoption path. Use a fresh development database and SDK secure-state store when testing this baseline. Preserve the deployment's existing signing keys; changing the format does not require key generation. -A token is two unpadded base64url strings separated by one dot: +## Encoding and signature -```text -base64url(UTF-8 JSON payload).base64url(64-byte Ed25519 signature) -``` +Tokens contain exactly two canonical unpadded base64url segments: `base64url(UTF-8 JSON bytes).base64url(Ed25519 signature)`. Verify the signature against the **original decoded bytes**, without reserializing JSON. Tokens are not JWTs. Use an authenticated update channel to pin the intended product/environment public keys. Discovery does not establish trust. Reject unknown key IDs, malformed encodings, unknown fields and versions, wrong issuer/product/installation/subject, future issuance and invalid timestamps. -The signature covers the **decoded UTF-8 JSON bytes**, exactly as returned by base64url-decoding the first segment. Do not reserialize the JSON or verify the encoded segment. There is no JWT header. Decoding is untrusted inspection, not verification. +## License v1 -## License v4 +Required fields: `v`, `kind:'license'`, `iss:'default'`, `productId`, `kid`, `installationId`, `licenseId`, `plan`, `entitlements`, `issuedTo`, `subject:{kind,id}`, `issuedAt`, nullable `expiresAt`, `offlineGraceUntil`, `status`, nullable `accountId`, positive `authorizationRevision`, nullable `paidImportsGranted`, nonnegative `recoveryGeneration`, and `trialConverted:true`. -Required fields: `v:4`, `kind:"license"`, `iss:"default"`, `productId`, `kid`, `licenseId`, `plan`, `entitlements`, `issuedTo`, `subject:{kind:"installation"|"service",id}`, `installationId`, `issuedAt`, nullable `expiresAt`, `offlineGraceUntil`, and `status:"active"|"revoked"|"expired"`. Entitlements are a map of finite numbers, strings or booleans. Subject ID must equal installation ID. +Products without local import quotas use `accountId:null`, `paidImportsGranted:null`, `recoveryGeneration:0`. These are explicit feature settings in the same v1 format. Import-account licenses require a non-null account, nonnegative cumulative paid allowance and an installation subject. Subject ID must equal installationId. An entitlement map remains a plan snapshot; cumulative purchased import allowance is `paidImportsGranted`. -Reject extra or missing fields, unknown versions, malformed timestamps or invalid types. Match issuer, product ID, subject kind and installation ID to your own expected context. Reject future issuance beyond your clock tolerance, negative status, passed expiry and passed offline deadline. Persist the maximum local clock and authenticated signed issuance time observed; the unsigned HTTP `serverTime` field is not a trusted time oracle. This makes basic clock rollback harder, but does not protect against a compromised device. +Authenticate and persist signed revoked/expired responses before returning a result; they supersede earlier active authorization. An active signature alone is insufficient. Enforce status, expiry, offline deadline, signed revision/generation and local usage integrity at the protected operation. Reject older revisions and conflicting terms at the same revision. A lease refresh may update mint time/deadline without adding allowance. Keep existing media readable when new imports are blocked. -## Trial v2 +## Trial v1 -Required fields: `v:2`, `kind:"trial"`, issuer, productId, kid, installationId, issuedAt, nullable expiresAt, `verdict:"fresh"|"spent"`, nullable nonnegative integer allowance and nullable supportReference. Accept only the expected product/installation, a valid signature and a fresh unexpired grant. A null allowance means the product has no numeric trial ceiling; interpret it according to your product policy. Persist consumption outside the grant. +Required fields: `v`, `kind:'trial'`, `iss`, `productId`, `kid`, `installationId`, `issuedAt`, nullable `expiresAt`, `verdict:'fresh'|'spent'`, nullable nonnegative `allowance`, nullable `supportReference`, and boolean `converted`. Enforce the signed limits and durable local trial consumption. Paid conversion permanently forfeits trial; refresh/restart never resets usage. Trial time/allowance rules are distinct from paid offline grace. -## Node signature example +## Recovery v1 -```js -import { verify } from "node:crypto"; +Recovery is separately verified with `kind:'usage-recovery'`. Required context fields are issuer/product/key/installation, issuance and a required expiry; additional fields are recoveryId, accountId, challenge, mode, nullable usageDigest, paidImportsFloor, trialImportsFloor, recoveryGeneration and authorizationRevision. It is never an application license. Approval is staff-controlled, target/challenge/account-bound and time-limited. Verify the matching ordinary license and pending backup commitment, then adopt atomically once. See [recovery](import-licensing.md#recovery-flow-and-limits). -function verifySignature(token, pinnedKeys) { - const parts = token.split("."); - if (parts.length !== 2 || parts.some(p => !/^[A-Za-z0-9_-]+$/.test(p))) return null; - const [encoded, signature] = parts; - let untrusted; - try { untrusted = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); } - catch { return null; } - const publicKey = pinnedKeys[untrusted.kid]; - if (!publicKey || !verify(null, Buffer.from(encoded, "base64url"), publicKey, - Buffer.from(signature, "base64url"))) return null; - // Apply the complete shape and contextual/time checks described above. - return untrusted; -} -``` +## Reference implementations and vectors -The example verifies only the cryptographic signature; do not use its return value as authorization before implementing the remaining checks. `src/token-contract.ts` defines server payload schemas. Prefer the complete verifier and authorization evaluator in [the TypeScript SDK](../packages/keyforge-client/README.md). `verifySignedToken` authenticates shape and context, including signed negative statuses; only the SDK's `getAuthorization` result indicates whether use is currently allowed. - -## Rotation - -Public product key discovery at `/v1/products/:productId/keys` helps operators distribute keys; downloading a key beside an untrusted token does not establish trust. Authenticate your distribution/update channel and pin approved keys. An admin stages a new key, distributes it to clients, then explicitly activates it. Retired public keys remain available for existing offline grants. Never activate before deployed clients trust the new kid. +Use SDK `@zdevzone/keyforge-client@0.2.0` and the [OpenAPI](client-openapi.json) schemas. Other languages can implement the same HTTPS/signature/storage contract without adding Node. [Portable v1 vectors](examples/client-contract/README.md) include checksums and test-only public keys. Run `vp exec tsx scripts/generate-client-fixtures.ts --check` to detect drift. Never use fixture keys for a deployment. diff --git a/docs/windows-device-identity.md b/docs/windows-device-identity.md index 97b2a7b..d1526a2 100644 --- a/docs/windows-device-identity.md +++ b/docs/windows-device-identity.md @@ -46,13 +46,13 @@ Initial collection happens only when creating SDK state. The SDK atomically save Call `await client.updateDeviceSignals()` while online to recollect and send recognition hints through the installation-authenticated PUT. It preserves the original registration body. Updates never increment trial counters, reset local consumption/server meters, change bindings, release seats, create licenses or modify sales. If collection is unavailable, current installation availability becomes Unavailable; historical evidence stays retained. Retrying an interrupted update is safe. -Legacy manual hashes still work and old records are retained. Namespaced hashes do not automatically match their legacy equivalents. Backfill authenticates the existing installation and adds evidence to its associated group without merging groups, even when another group has matching hashes. A legacy authenticated identity without a group gets a reporting group with zero counted registrations; backfill itself grants no trial. Credentialless legacy identities cannot be claimed. +Authenticated signal updates enrich an existing registered identity without consuming seats or restarting trials. Only the documented v1 hash scheme is used. Two distinct matching kinds can associate a **new registration** with a group under existing trial policy; common hashes are excluded. Hardware may be missing, replaced, cloned or forged. Matching never transfers a paid seat. Lost credentials require a new identity and operator-assisted seat recovery. Windows reinstalls and hardware/volume changes may affect recognition. Restore secure app state when possible; never use a hash as a credential. ## Console interpretation -- **Installations:** authenticated identities, active licensed counts, current signal kinds and group references. Available means all three kinds; Partial means one or two; Unavailable means none. Legacy rows without individual metadata fall back to historical evidence until backfilled. +- **Installations:** authenticated identities, active licensed counts, current signal kinds and group references. Available means all three kinds; Partial means one or two; Unavailable means none. Missing individual signal metadata is shown as Unavailable; group evidence is never substituted for an installation report. - **Machines:** reported groups and historical evidence, registration counts and trial clear state. Availability describes retained evidence, not a live inspection. Last registration and last signal update are separate timestamps. - **License activations:** binding history and active installation seats, with group references. Release permissions and installation limits are unchanged. diff --git a/packages/create-keyforge-client/package.json b/packages/create-keyforge-client/package.json index a83cacb..c6b8cb9 100644 --- a/packages/create-keyforge-client/package.json +++ b/packages/create-keyforge-client/package.json @@ -1,6 +1,6 @@ { "name": "create-keyforge-client", - "version": "0.1.1", + "version": "0.2.0", "description": "Create a Node.js KeyForge integration starter with explicit secure storage", "license": "MIT", "repository": { @@ -27,5 +27,5 @@ "engines": { "node": "^22.18.0 || ^24.11.0 || ^26.0.0" }, - "keyforgeSdkVersion": "^0.1.1" + "keyforgeSdkVersion": "^0.2.0" } diff --git a/packages/create-keyforge/package.json b/packages/create-keyforge/package.json index 51fecd3..c3c024d 100644 --- a/packages/create-keyforge/package.json +++ b/packages/create-keyforge/package.json @@ -1,6 +1,6 @@ { "name": "create-keyforge", - "version": "1.0.2", + "version": "1.0.3", "description": "Scaffold a self-hosted Keyforge license server: pick a database, an operator-admin scope, and a licensing model, and get a configured project ready to deploy.", "keywords": [ "create", diff --git a/packages/create-keyforge/src/template.mjs b/packages/create-keyforge/src/template.mjs index 203f907..48df5dd 100644 --- a/packages/create-keyforge/src/template.mjs +++ b/packages/create-keyforge/src/template.mjs @@ -42,7 +42,6 @@ const PRUNE = [ "test/import-licensing.test.ts", // A dated review of upstream's deployment is not a new deployer's configuration. "docs/studio-proof-contract.md", - "docs/examples/studio-proof-contract", "test/scaffold-prompts.test.ts", "scripts/package-smoke.mjs", "scripts/release-packages.mjs", diff --git a/packages/keyforge-client/README.md b/packages/keyforge-client/README.md index e67732d..a85d752 100644 --- a/packages/keyforge-client/README.md +++ b/packages/keyforge-client/README.md @@ -1,6 +1,9 @@ # KeyForge TypeScript client -`@zdevzone/keyforge-client` provides online onboarding, registration/activation, signed offline authorization, trial accounting, public support threads and in-app notifications. It supports the existing product-scoped server API, license v4 and trial v2. It uses Fetch, Web Crypto Ed25519, and Zod; it has no server, database, UI or desktop-framework dependency. Node 22.18+ is the reference runtime. Other desktop runtimes must support those Web APIs or run the client in their trusted Node/main process. +**0.2.0 is the fresh v1 baseline.** License, trial and usage-recovery grants use v1 only. Previous development tokens, databases and cached SDK state are unsupported; start with a fresh database and new installation state while retaining server signing keys. Production storage must implement atomic compare-and-swap. Use `consumeImport(eventId, units)` for trial and paid imports; there is no counter-adoption or old-format conversion API. + + +`@zdevzone/keyforge-client` provides online onboarding, registration/activation, signed offline authorization, trial accounting, public support threads and in-app notifications. It supports the existing product-scoped server API, license, trial and recovery v1. It uses Fetch, Web Crypto Ed25519, and Zod; it has no server, database, UI or desktop-framework dependency. Node 22.18+ is the reference runtime. Other desktop runtimes must support those Web APIs or run the client in their trusted Node/main process. Install in an existing application: @@ -65,7 +68,7 @@ Customers enter their **product/license key**. The SDK generates a different, ra New activation, validation, trial onboarding and refresh require internet. Offline usage checks the authenticity and remaining lifetime of a previously saved grant; it cannot validate a new product key. Paid authorization ends at the earlier of signed expiry and `offlineGraceUntil`. Expiry deadlines have no grace extension for clock tolerance. A signed revoked/expired refresh can arrive with HTTP 200 and is persisted before returning. Refresh failures do not erase a still-usable signed grant for transient network outages; explicit binding/identity denials prevent fallback to an older active grant. -Trials retain their distinct existing contract: `fresh`/`spent`, nullable expiry and nullable allowance. An allowance-only trial may have no time deadline. Call `consumeTrial(units)` at the app's billable trial operation; it serializes and saves cumulative usage separately from grants. Activation, refresh, registration replay and app restarts never reset this counter. Its return state describes authorization **after** consumption, so inspect the pre-operation allowance before starting work and retain the consumed result for the next operation. App-local accounting is mutable on a compromised device; authoritative usage belongs on a trusted backend. A trial grant does not contain paid plan entitlements. +Trials retain their distinct existing contract: `fresh`/`spent`, nullable expiry and nullable allowance. An allowance-only trial may have no time deadline. Call `consumeImport(eventId, units)` at the app's billable trial operation; it serializes and saves cumulative usage separately from grants. Activation, refresh, registration replay and app restarts never reset this counter. Its return state describes authorization **after** consumption, so inspect the pre-operation allowance before starting work and retain the consumed result for the next operation. App-local accounting is mutable on a compromised device; authoritative usage belongs on a trusted backend. A trial grant does not contain paid plan entitlements. ## Storage and trust boundaries @@ -131,17 +134,16 @@ UUIDs normalize to trimmed lowercase unbraced hyphenated hex. Volume serials nor Initial signals and diagnostics are saved atomically with the registration UUID/body before networking. The saved request is replayed unchanged after interruption/restart; hardware changes never rewrite it. Manual `registration.machineSignals` still take precedence for initial registration. `updateDeviceSignals()` requires the provider and uses the installation-authenticated PUT; no trial consumption, seat, meter, grant or financial state changes. Empty reports clear current availability but retain historical group evidence. Retry the update after network recovery. -Legacy hashes and namespaced hashes do not automatically recognize one another. Authenticated backfill anchors new evidence to the existing installation's group without automatic group merges. A server URL/product change changes the namespace and storage context; coordinate a credential-preserving migration/backfill rather than deleting state. Lost credentials need operator-assisted recovery. Signals can be changed, cloned or forged; they are recognition hints, not physical-device proof. Activation limits still count installations, and matching hardware never automatically transfers/releases seats. Keep production credentials in your application-supplied secure storage adapter; never silently fall back to plaintext files. +Only namespaced signal hashes from the current v1 setup should be used. Authenticated updates anchor new evidence to the existing installation's group without automatic group merges. A server URL/product change changes the namespace and storage context; coordinate a credential-preserving migration/backfill rather than deleting state. Lost credentials need operator-assisted recovery. Signals can be changed, cloned or forged; they are recognition hints, not physical-device proof. Activation limits still count installations, and matching hardware never automatically transfers/releases seats. Keep production credentials in your application-supplied secure storage adapter; never silently fall back to plaintext files. ## Import accounts and approved recovery -This source version supports optional license v5 / trial v3; published older packages may not. Deploy updated clients before product opt-in. Read [the complete import contract](../../docs/import-licensing.md) and the current OpenAPI. Legacy v4/v2 remain supported without changing their payloads. +SDK 0.2.0 supports only the clean v1 contract. Use a fresh development server database and secure-state store. Existing deployment signing keys are retained; there is no token/state conversion or old parser. Production deployment is separate. Read [the complete contract](../../docs/import-licensing.md). - `consumeImport(eventId, units=1)` records one idempotent successful operation and returns its receipt/authorization. Use a UUID durable with the media operation; retries with changed units conflict. Do not charge failed work, duplicates or previews. `getImportUsage()` exposes separate paid/trial/lifetime accounting. -- Import operations require `SecureStorage.compareAndSwap(expectedRevision, nextState)`: expected null means absent state, otherwise compare the integer storageRevision (legacy absence=0) and atomically replace the whole snapshot. Secure the data and implement this across all processes. A normal atomic file rename alone is not CAS. MemoryStorage is test-only. +- Import operations require `SecureStorage.compareAndSwap(expectedRevision, nextState)`: expected null means absent state, otherwise compare the integer storageRevision (required) and atomically replace the whole snapshot. Secure the data and implement this across all processes. A normal atomic file rename alone is not CAS. MemoryStorage is test-only. - Enlist CAS and the media insert in the same application database transaction. If those stores cannot share a transaction, implement a durable reconciled import journal before shipping; do not claim SDK serialization makes independent stores atomic. After storage_conflict, reload and retry the same event ID. - Remaining paid allowance is the signed cumulative paidImportsGranted minus local paidConsumed. Trial consumption never reduces purchased units. Renewal/top-up add once; refresh, key replacement and recording a receipt add none. Paid conversion permanently forfeits trial. quota_exhausted and recovery_required deny new imports, not access to existing work. -- `adoptLegacyImportUsage({lifetimeImports,paidConsumed,trialConsumed,lifetimeKnown})` is for verified surviving legacy history only. It refuses lowering known counters or reinitializing an adopted account. Missing/corrupt history must use recovery. A v5 account rejects protocol downgrade, old revisions and conflicting same-revision terms. - `exportUsageBackup(passphrase)` returns a separate AES-256-GCM/PBKDF2 encrypted usage snapshot. Use a strong separately retained passphrase and off-device copies; never include it in ordinary configuration exports. It excludes credentials. A valid backup does not prove freshness. - `markUsageRecoveryRequired()` blocks import use while retaining known evidence. `requestUsageRecovery(licenseId, {encrypted,password})` commits a pending UUID/challenge/candidate before networking; omit backup for lost history. Retry without supplying new backup data, preserving the pending request. A product key does not approve recovery. - After independent staff approval, `claimUsageRecovery()` verifies both recovery/license signatures, nonce, account, installation, revision/generation and backup commitment, then adopts once atomically. The approval expires after24 hours; stale/expired/rejected cases need an explicit `abandonUsageRecovery()` and a new approval. `verifyRecoveryToken()` is separate from `verifySignedToken()`; a recovery artifact never authorizes paid use by itself. diff --git a/packages/keyforge-client/package.json b/packages/keyforge-client/package.json index 0cafde7..1cb3018 100644 --- a/packages/keyforge-client/package.json +++ b/packages/keyforge-client/package.json @@ -1,6 +1,6 @@ { "name": "@zdevzone/keyforge-client", - "version": "0.1.1", + "version": "0.2.0", "description": "Typed KeyForge installation client and signed offline authorization verifier", "license": "MIT", "repository": { diff --git a/packages/keyforge-client/skill/keyforge-integrate/SKILL.md b/packages/keyforge-client/skill/keyforge-integrate/SKILL.md index 7147489..c18e111 100644 --- a/packages/keyforge-client/skill/keyforge-integrate/SKILL.md +++ b/packages/keyforge-client/skill/keyforge-integrate/SKILL.md @@ -13,11 +13,11 @@ Read [the integration contract](references/integration.md) before changing licen - Add the publishable SDK archive/workspace dependency using the target repository's package manager. The package may not yet be on a public registry; obtain the built archive/source rather than installing an unrelated similarly named package. Respect target framework/version and existing UI components. - Implement and test a platform-appropriate `SecureStorage` adapter. Keep one SDK owner in a trusted process, expose only required status/actions to UI, and atomically persist a coherent state record. Use the existing credential vault or a vault-held encryption key plus atomic encrypted-file replacement. Never silently fall back to plaintext persistence or `MemoryStorage` in production. -- For opt-in import accounts, read the packaged README's import/recovery section and `docs/import-licensing.md` when source is available. Require atomic cross-process `compareAndSwap`; enlist usage and asset writes in the same host database transaction. Use stable import event UUIDs and separate trial/paid/lifetime counters. Migrate verified legacy history explicitly; never infer zero from missing usage. Handle quota_exhausted/recovery_required while preserving access to existing media. V5/v3 require an updated SDK; v4/v2 remain for legacy products. +- For opt-in import accounts, read the packaged README's import/recovery section and `docs/import-licensing.md` when source is available. Require atomic cross-process `compareAndSwap`; enlist usage and asset writes in the same host database transaction. Use stable import event UUIDs and separate trial/paid/lifetime counters. Use fresh v1 state; never infer zero from missing usage. Handle quota_exhausted/recovery_required while preserving access to existing media. Only SDK 0.2.0 and the clean v1 contract are supported. - Recovery uses customer-encrypted backups and a separate, challenge-bound, expiring staff-approved artifact. Use the SDK request/claim workflow and persist its pending state across failures. No ordinary key, hardware hint or skill invocation authorizes resetting counters. Never implement local unsigned recovery or claim protection against complete OS/state rollback. - Offer online key activation and provider-permitted trial onboarding. Skipping leaves the app unlicensed with only explicitly free app features. Enforce access at actual protected operations, including backend/IPC boundaries where present, rather than only hiding buttons. - Refresh while online ahead of the signed deadline; evaluate cached authorization on startup and at protected operations. Handle expired/revoked/reconnect states visibly. Keep app-specific trial usage durable and separate from token refresh. Preserve the target app's work/data when authorization ends. -- For optional Windows recognition, import `createWindowsDeviceSignalsProvider` from `@zdevzone/keyforge-client/windows` in the trusted Node process and supply `deviceSignalsProvider`. Keep Node imports out of browser/mobile bundles. Do not collect CPU IDs, MAC/IP addresses or send raw identifiers. Read the SDK's Windows diagnostics and recovery guidance; partial/unsupported reports must leave installation-based licensing usable. Backfill existing installations with `updateDeviceSignals()`, never a new registration request. Preserve secure storage and the original registration body across restarts. Explain URL/product scoping, legacy backfill and recognition limitations; do not describe this as physical-device enforcement. +- For optional Windows recognition, import `createWindowsDeviceSignalsProvider` from `@zdevzone/keyforge-client/windows` in the trusted Node process and supply `deviceSignalsProvider`. Keep Node imports out of browser/mobile bundles. Do not collect CPU IDs, MAC/IP addresses or send raw identifiers. Read the SDK's Windows diagnostics and recovery guidance; partial/unsupported reports must leave installation-based licensing usable. Backfill existing installations with `updateDeviceSignals()`, never a new registration request. Preserve secure storage and the original registration body across restarts. Explain URL/product scoping, authenticated signal updates and recognition limitations; do not describe this as physical-device enforcement. - For Windows/Linux/macOS targets, prefer the automatic `createDeviceSignalsProvider()` from `@zdevzone/keyforge-client/device-signals`; explicit `/linux` and `/macos` factories also exist. Never request sudo to read Linux DMI identifiers. Honor missing/permission-denied signals and OS-specific complete sets (Mac two; Windows/Linux three). Browser/mobile sandbox limitations remain. For non-JS/TS apps, use the HTTP/OpenAPI and exact hash/token contracts; do not claim a native SDK exists or introduce Node just to wrap REST. Consult `docs/device-identity.md` when available and the packaged README; choose language-specific ports only for a real target's needs. - For optional inbox/notifications, use public customer methods and polling/backoff. Add a durable app outbox for support messages; keep UUID/body unchanged after timeouts. Do not imply that email delivery or customer portal accounts exist. diff --git a/packages/keyforge-client/skill/keyforge-integrate/references/integration.md b/packages/keyforge-client/skill/keyforge-integrate/references/integration.md index 48bd115..c95a81d 100644 --- a/packages/keyforge-client/skill/keyforge-integrate/references/integration.md +++ b/packages/keyforge-client/skill/keyforge-integrate/references/integration.md @@ -28,15 +28,15 @@ const client = new KeyForgeClient({ - `refresh()` requires a bound license and internet. Schedule before the returned paid `deadline`; the SDK does not run background workers. Transport retries bounded network/429/502/503/504 failures. A transient failure leaves an otherwise usable cached grant. Explicit binding/identity denials block fallback. - `deactivate()` releases the seat online, clears local paid use after success, and does not silently enable trial use. - States: `unlicensed`, `trial`, `licensed`, `reconnect_required`, `expired`, `revoked`. Reason codes explain failures. Correct a rolled-back system clock before reconnecting. -- `consumeTrial(units)` persists cumulative consumption independently of grants and returns post-consumption state. Check available units before allowing work. No refresh/restart may reset consumption. Local counters are not authoritative on compromised devices. +- `consumeImport(eventId, units)` persists cumulative consumption independently of grants and returns post-consumption state. Check available units before allowing work. No refresh/restart may reset consumption. Local counters are not authoritative on compromised devices. ## Verification invariants `createDeviceSignalsProvider()` from `@zdevzone/keyforge-client/device-signals` automatically selects Windows, Linux or macOS in a trusted Node process. Explicit `/linux` and `/macos` exports are available; the `/windows` contract remains compatible. Consult the packaged README or `docs/device-identity.md` for per-OS sources and hash vectors. Never elevate to obtain DMI signals. Unsupported/sandboxed platforms still use installation credentials, not fabricated fingerprints. Only JS/TS has an official SDK; other languages implement the same HTTPS, secure-storage, retry and signed-token contracts. OpenAPI generation covers HTTP plumbing, not offline authorization. -For opt-in Windows recognition, use `@zdevzone/keyforge-client/windows` only in a trusted Node process. Follow the SDK README's exact v1 normalization/hash scope; never invent an all-platform fingerprint. Preserve pending registration signals before networking. Use `getDeviceSignalsDiagnostics()` to explain partial/unsupported collection and `updateDeviceSignals()` for existing authenticated identities. Backfill does not register, reset trial consumption, change meters/seats or merge groups. Test permission/timeout/placeholder cases, interrupted registration with changed hardware, legacy backfill and wrong-product credentials. URL/product changes require coordinated state migration, not silent identity replacement. Hardware signals are forgeable hints; do not promise physical-device enforcement or offline seat transfer. +For opt-in Windows recognition, use `@zdevzone/keyforge-client/windows` only in a trusted Node process. Follow the SDK README's exact v1 normalization/hash scope; never invent an all-platform fingerprint. Preserve pending registration signals before networking. Use `getDeviceSignalsDiagnostics()` to explain partial/unsupported collection and `updateDeviceSignals()` for existing authenticated identities. Backfill does not register, reset trial consumption, change meters/seats or merge groups. Test permission/timeout/placeholder cases, interrupted registration with changed hardware, authenticated signal updates and wrong-product credentials. URL/product changes require coordinated state migration, not silent identity replacement. Hardware signals are forgeable hints; do not promise physical-device enforcement or offline seat transfer. -License v4/trial v2 tokens are `base64url(JSON bytes).base64url(Ed25519 signature)`. The signature covers **decoded UTF-8 JSON bytes**, without reserialization. These are not JWTs. `verifySignedToken` authenticates payload/context even for negative statuses; it alone does not grant access. Prefer `getAuthorization` for permission. +license v1/trial v1 tokens are `base64url(JSON bytes).base64url(Ed25519 signature)`. The signature covers **decoded UTF-8 JSON bytes**, without reserialization. These are not JWTs. `verifySignedToken` authenticates payload/context even for negative statuses; it alone does not grant access. Prefer `getAuthorization` for permission. Pin approved SPKI public PEMs by `kid`. Never automatically trust keys fetched beside a token. Reject malformed payloads, unknown versions/kids, wrong issuer/product/installation/subject, implausible future issuance and invalid signatures. Paid access ends at the earlier of expiry and offline grace. A signed revoked/expired refresh can be HTTP 200: save it before evaluating access. Trial `spent`, allowance exhaustion and expiry fail closed. Trials use their own nullable expiry/allowance contract, not the paid offline-grace field. Offline revocation is bounded by the signed deadline; no client can promise immediate offline revocation or prevent all local tampering. @@ -52,6 +52,6 @@ Use `listSupportThreads()`/`getSupportThread(id)`. `createSupportThread` takes ` ## Optional import-account contract -Opt-in products may return paid v5 and trial v3. Follow the packaged README import/recovery section (and docs/import-licensing.md when available). Add mandatory cross-process storage CAS, idempotent import receipts inside the asset transaction, permanent separate trial/paid counters and signed revision/generation high-water marks. Preserve read access when import authorization fails. Do not interpret a plan allowance as an already consumed lifetime ceiling. +Opt-in products may return v1 license and trial artifacts. Follow the packaged README import/recovery section (and docs/import-licensing.md when available). Add mandatory cross-process storage CAS, idempotent import receipts inside the asset transaction, permanent separate trial/paid counters and signed revision/generation high-water marks. Preserve read access when import authorization fails. Do not interpret a plan allowance as an already consumed lifetime ceiling. Recovery is a separate usage-recovery v1 signature bound to product/account/installation, a durable challenge, candidate commitment, short expiry and one generation. Request/claim through the SDK, never bypass integrity with an ordinary license key. Staff approval remains outside the distributed app. Verify encrypted backup context and preserve newer known counters; lost history consumes previous allowance and remains unknown, not zero. Test replay after lost response/save failure, stale approvals, forged recovery, wrong context, trial conversion, additive renewals and concurrent CAS. Do not deploy or publish server/packages without the user's separate release instruction. diff --git a/packages/keyforge-client/src/client.ts b/packages/keyforge-client/src/client.ts index ece03d1..6eacb0d 100644 --- a/packages/keyforge-client/src/client.ts +++ b/packages/keyforge-client/src/client.ts @@ -50,7 +50,7 @@ export class MemoryStorage implements SecureStorage { this.state = structuredClone(state); } async compareAndSwap(expectedRevision: number | null, state: ClientState) { - if ((this.state ? (this.state.storageRevision ?? 0) : null) !== expectedRevision) return false; + if ((this.state ? this.state.storageRevision : null) !== expectedRevision) return false; this.state = structuredClone(state); return true; } @@ -93,6 +93,7 @@ const metadataSchema = z .strict(); const stateSchema = z .object({ + format: z.literal("keyforge-client-state"), version: z.literal(1), serverUrl: z.string(), productId: z.string(), @@ -105,13 +106,12 @@ const stateSchema = z }), registered: z.boolean(), trialStarted: z.boolean(), - trialConsumed: z.number().int().nonnegative().safe(), trialToken: z.string().nullable(), licenseToken: z.string().nullable(), denial: z.string().nullable(), lastObservedTime: z.number().finite().nonnegative(), - storageRevision: z.number().int().nonnegative().safe().optional(), - importUsage: importUsageSchema.nullable().optional(), + storageRevision: z.number().int().nonnegative().safe(), + importUsage: importUsageSchema, pendingRecovery: z .object({ requestId: z.string().uuid(), @@ -122,8 +122,7 @@ const stateSchema = z id: z.string().optional(), }) .strict() - .nullable() - .optional(), + .nullable(), deviceSignals: z .object({ signals: metadataSchema.shape.machineSignals.unwrap(), @@ -158,6 +157,7 @@ export class KeyForgeClient { constructor(options: ClientOptions) { if ( !options.storage || + typeof options.storage.compareAndSwap !== "function" || !options.publicKeys || !/^[a-z][a-z0-9-]{0,59}$/.test(options.productId) ) @@ -224,6 +224,7 @@ export class KeyForgeClient { ? await this.collectDeviceSignals() : undefined; const state: ClientState = { + format: "keyforge-client-state", version: 1, serverUrl: this.url, productId: this.options.productId, @@ -240,7 +241,6 @@ export class KeyForgeClient { }, registered: false, trialStarted: false, - trialConsumed: 0, trialToken: null, licenseToken: null, denial: null, @@ -258,13 +258,11 @@ export class KeyForgeClient { if (!this.options.storage.compareAndSwap) throw new KeyForgeError("atomic_storage_required"); } private async persist(state: ClientState, creating = false) { - const revision = state.storageRevision ?? 0; + const revision = state.storageRevision; if (!Number.isSafeInteger(revision + 1)) throw new KeyForgeError("invalid_stored_state"); const next = { ...state, storageRevision: revision + 1 }; - if (this.options.storage.compareAndSwap) { - if (!(await this.options.storage.compareAndSwap(creating ? null : revision, next))) - throw new KeyForgeError("storage_conflict"); - } else await this.options.storage.save(next); + if (!(await this.options.storage.compareAndSwap(creating ? null : revision, next))) + throw new KeyForgeError("storage_conflict"); state.storageRevision = next.storageRevision; } private async collectDeviceSignals(): Promise { @@ -409,8 +407,7 @@ export class KeyForgeClient { if (response.trial) { const grant = await verifySignedToken(response.trial.token, this.context(state)); if (grant.kind !== "trial") throw new TokenError("unexpected_token_kind"); - if (grant.v === 3 && grant.converted && state.importUsage) - state.importUsage.trialConverted = true; + if (grant.converted && state.importUsage) state.importUsage.trialConverted = true; state.trialToken = response.trial.token; this.observe(state, grant); } @@ -450,7 +447,7 @@ export class KeyForgeClient { return { state: "expired", reason: "license_expired" }; if (Date.parse(grant.offlineGraceUntil) <= time) return { state: "reconnect_required", reason: "offline_grace_expired" }; - if (grant.v === 5) { + if (grant.accountId !== null) { const usage = state.importUsage; if ( !usage?.integrity || @@ -458,7 +455,7 @@ export class KeyForgeClient { usage.recoveryGeneration !== grant.recoveryGeneration ) return { state: "recovery_required", reason: "usage_integrity_required" }; - if (usage.paidConsumed >= grant.paidImportsGranted) + if (usage.paidConsumed >= grant.paidImportsGranted!) return { state: "quota_exhausted", reason: "paid_imports_exhausted" }; } const deadline = new Date( @@ -472,24 +469,25 @@ export class KeyForgeClient { token: grant, entitlements: grant.entitlements, deadline, - ...(grant.v === 5 - ? { remainingImports: grant.paidImportsGranted - state.importUsage!.paidConsumed } + ...(grant.accountId !== null + ? { remainingImports: grant.paidImportsGranted! - state.importUsage.paidConsumed } : {}), }; } - if (state.importUsage?.trialConverted || (grant.v === 3 && grant.converted)) + if (state.importUsage.trialConverted || grant.converted) return { state: "expired", reason: "trial_converted" }; - if (grant.v === 3 && !state.importUsage?.integrity) + if (!state.importUsage.integrity) return { state: "recovery_required", reason: "usage_integrity_required" }; if (grant.verdict !== "fresh") return { state: "expired", reason: "trial_spent" }; if (grant.expiresAt !== null && Date.parse(grant.expiresAt) <= time) return { state: "expired", reason: "trial_expired" }; - if (grant.allowance !== null && state.trialConsumed >= grant.allowance) + if (grant.allowance !== null && state.importUsage.trialConsumed >= grant.allowance) return { state: "expired", reason: "trial_exhausted" }; return { state: "trial", token: grant, - remaining: grant.allowance === null ? null : grant.allowance - state.trialConsumed, + remaining: + grant.allowance === null ? null : grant.allowance - state.importUsage.trialConsumed, }; } /** Registers online but does not select a trial or unlock features. */ @@ -504,12 +502,12 @@ export class KeyForgeClient { installationId: state.installationId, trialAvailable: grant?.kind === "trial" && - !state.importUsage?.trialConverted && - !(grant.v === 3 && grant.converted) && + !state.importUsage.trialConverted && + !grant.converted && grant.verdict === "fresh" && (grant.expiresAt === null || Date.parse(grant.expiresAt) > Math.max(this.now(), state.lastObservedTime)) && - (grant.allowance === null || grant.allowance > state.trialConsumed), + (grant.allowance === null || grant.allowance > state.importUsage.trialConsumed), }; }); } @@ -518,7 +516,7 @@ export class KeyForgeClient { const state = await this.load(), wasRegistered = state.registered; await this.ensureRegistered(state); - if (state.importUsage?.trialConverted) throw new KeyForgeError("trial_converted"); + if (state.importUsage.trialConverted) throw new KeyForgeError("trial_converted"); // Explicit trial onboarding requires connectivity even if registration was completed earlier. if (wasRegistered) await this.request(state, "POST", this.productPath("/register"), state.registration); @@ -530,31 +528,7 @@ export class KeyForgeClient { getAuthorization(): Promise { return this.serial(async () => this.evaluate(await this.load())); } - /** Saves usage independently of grants. Refresh/activation never resets this counter. */ - consumeTrial(units = 1): Promise { - return this.serial(async () => { - if (!Number.isSafeInteger(units) || units <= 0) throw new KeyForgeError("invalid_units"); - const state = await this.load(), - authorization = await this.evaluate(state); - if ( - authorization.state !== "trial" || - (authorization.remaining !== null && units > authorization.remaining) - ) - throw new KeyForgeError("trial_unavailable"); - if (authorization.token.v === 3) this.requireAtomicStorage(); - if (!Number.isSafeInteger(state.trialConsumed + units)) - throw new KeyForgeError("invalid_units"); - state.trialConsumed += units; - if (state.importUsage) { - if (!Number.isSafeInteger(state.importUsage.lifetimeImports + units)) - throw new KeyForgeError("invalid_units"); - state.importUsage.trialConsumed = state.trialConsumed; - state.importUsage.lifetimeImports += units; - } - await this.persist(state); - return this.evaluate(state); - }); - } + /** Online key inspection does not activate a license. */ validate(key: string): Promise { return this.serial(async () => { const state = await this.load(); @@ -584,22 +558,18 @@ export class KeyForgeClient { const grant = await verifySignedToken(token, this.context(state)); if (grant.kind !== "license" || (expectedLicenseId && grant.licenseId !== expectedLicenseId)) throw new TokenError("unexpected_license"); - if (grant.v === 4 && state.importUsage?.accountId) throw new TokenError("protocol_downgrade"); - if (grant.v === 4 && state.importUsage) state.importUsage.legacyPaidSeen = true; - if (grant.v === 5) { - this.requireAtomicStorage(); + if (grant.accountId === null && state.importUsage.accountId !== null) + throw new TokenError("account_mismatch"); + state.importUsage.trialConverted = true; + state.trialStarted = false; + if (grant.accountId !== null) { const usage = state.importUsage; - if (!usage || (usage.legacyPaidSeen && !usage.legacyVerified)) { - state.denial = "usage_migration_required"; - await this.persist(state); - throw new KeyForgeError("usage_migration_required"); - } if (usage.accountId && usage.accountId !== grant.accountId) throw new TokenError("account_mismatch"); if ( grant.authorizationRevision < usage.authorizationRevision || grant.recoveryGeneration < usage.recoveryGeneration || - grant.paidImportsGranted < usage.granted + grant.paidImportsGranted! < usage.granted ) throw new TokenError("stale_grant"); // Mint time / lease renewal may change at the same revision; entitlement terms may not. @@ -623,7 +593,7 @@ export class KeyForgeClient { throw new TokenError("revision_conflict"); usage.accountId = grant.accountId; usage.authorizationRevision = grant.authorizationRevision; - usage.granted = grant.paidImportsGranted; + usage.granted = grant.paidImportsGranted!; usage.termsHash = terms; usage.trialConverted = true; state.trialStarted = false; @@ -636,15 +606,32 @@ export class KeyForgeClient { } catch (error) { if (!(error instanceof TokenError)) throw error; } - if ( - prior?.kind === "license" && - prior.licenseId === grant.licenseId && - (Date.parse(grant.issuedAt) < Date.parse(prior.issuedAt) || + if (prior?.kind === "license" && prior.licenseId === grant.licenseId) { + if ( + grant.authorizationRevision < prior.authorizationRevision || + Date.parse(grant.issuedAt) < Date.parse(prior.issuedAt) || (grant.issuedAt === prior.issuedAt && prior.status !== "active" && - grant.status === "active")) - ) - throw new TokenError("stale_grant"); + grant.status === "active") + ) + throw new TokenError("stale_grant"); + const terms = (value: typeof grant) => ({ + plan: value.plan, + entitlements: value.entitlements, + subject: value.subject, + issuedTo: value.issuedTo, + expiresAt: value.expiresAt, + status: value.status === "expired" ? "active" : value.status, + accountId: value.accountId, + paidImportsGranted: value.paidImportsGranted, + recoveryGeneration: value.recoveryGeneration, + }); + if ( + grant.authorizationRevision === prior.authorizationRevision && + (await usageDigest(terms(grant))) !== (await usageDigest(terms(prior))) + ) + throw new TokenError("revision_conflict"); + } } state.licenseToken = token; state.denial = null; @@ -701,33 +688,8 @@ export class KeyForgeClient { await this.persist(state); }); } - /** Initialize a legacy client's VERIFIED surviving history once; never use this to repair corrupt usage. */ - adoptLegacyImportUsage(input: { - lifetimeImports: number; - paidConsumed: number; - trialConsumed: number; - lifetimeKnown: boolean; - }) { - return this.serial(async () => { - this.requireAtomicStorage(); - const state = await this.load(); - if (state.importUsage?.accountId || state.importUsage?.legacyVerified) - throw new KeyForgeError("usage_already_initialized"); - const previous = state.importUsage ?? emptyImportUsage(); - const usage = importUsageSchema.parse({ ...previous, ...input, legacyVerified: true }); - if ( - usage.trialConsumed < state.trialConsumed || - usage.paidConsumed < previous.paidConsumed || - usage.lifetimeImports < previous.lifetimeImports - ) - throw new KeyForgeError("usage_rollback"); - state.importUsage = usage; - state.trialConsumed = usage.trialConsumed; - await this.persist(state); - }); - } getImportUsage() { - return this.serial(async () => structuredClone((await this.load()).importUsage ?? null)); + return this.serial(async () => structuredClone((await this.load()).importUsage)); } /** One stable UUID per successful asset operation. Production adapters must share the asset transaction. */ consumeImport(eventId: string, units = 1) { @@ -741,7 +703,7 @@ export class KeyForgeClient { throw new KeyForgeError("invalid_units"); const state = await this.load(), usage = state.importUsage; - if (!usage?.integrity) throw new KeyForgeError("usage_integrity_required"); + if (!usage.integrity) throw new KeyForgeError("usage_integrity_required"); const prior = usage.events[eventId]; if (prior) { if (prior.units !== units) throw new KeyForgeError("idempotency_conflict"); @@ -749,7 +711,7 @@ export class KeyForgeClient { } const auth = await this.evaluate(state, false); const source = auth.state === "trial" ? "trial" : "paid"; - if (auth.state !== "trial" && (auth.state !== "licensed" || auth.token.v !== 5)) + if (auth.state !== "trial" && (auth.state !== "licensed" || auth.token.accountId === null)) throw new KeyForgeError("imports_unavailable"); const remaining = auth.state === "trial" ? auth.remaining : auth.remainingImports!; if (remaining !== null && units > remaining) throw new KeyForgeError("quota_exhausted"); @@ -758,7 +720,6 @@ export class KeyForgeClient { usage.lifetimeImports = next; if (source === "trial") { usage.trialConsumed += units; - state.trialConsumed = usage.trialConsumed; } else usage.paidConsumed += units; const receipt = { units, source } as const; usage.events[eventId] = receipt; @@ -770,7 +731,7 @@ export class KeyForgeClient { return this.serial(async () => { this.requireAtomicStorage(); const state = await this.load(); - if (state.importUsage) state.importUsage.integrity = false; + state.importUsage.integrity = false; await this.persist(state); }); } @@ -780,7 +741,7 @@ export class KeyForgeClient { exportUsageBackup(password: string) { return this.serial(async () => { const usage = (await this.load()).importUsage; - if (!usage?.integrity) throw new KeyForgeError("usage_integrity_required"); + if (!usage.integrity) throw new KeyForgeError("usage_integrity_required"); return encryptUsageBackup(usage, password, this.backupContext()); }); } @@ -851,7 +812,7 @@ export class KeyForgeClient { if ( recovery.challenge !== pending.challenge || grant.kind !== "license" || - grant.v !== 5 || + grant.accountId === null || grant.licenseId !== pending.licenseId || grant.accountId !== recovery.accountId || grant.recoveryGeneration !== recovery.recoveryGeneration || @@ -872,7 +833,7 @@ export class KeyForgeClient { const usage = structuredClone( recovery.mode === "restore" ? pending.candidate! : emptyImportUsage(), ); - const previous = old ?? emptyImportUsage(); + const previous = old; usage.paidConsumed = Math.max( usage.paidConsumed, previous.paidConsumed, @@ -881,7 +842,7 @@ export class KeyForgeClient { usage.trialConsumed = Math.max( usage.trialConsumed, previous.trialConsumed, - state.trialConsumed, + previous.trialConsumed, recovery.trialImportsFloor, ); usage.lifetimeImports = Math.max(usage.lifetimeImports, previous.lifetimeImports); @@ -919,7 +880,6 @@ export class KeyForgeClient { // A valid old snapshot is not proof that the lost interval contained no imports. usage.lifetimeKnown = false; state.importUsage = importUsageSchema.parse(usage); - state.trialConsumed = usage.trialConsumed; state.pendingRecovery = null; // saveLicense persists both adoption and intent clearing in a single atomic snapshot. await this.saveLicense(state, response.token, pending.licenseId); diff --git a/packages/keyforge-client/src/tokens.ts b/packages/keyforge-client/src/tokens.ts index 3523fc1..24980bd 100644 --- a/packages/keyforge-client/src/tokens.ts +++ b/packages/keyforge-client/src/tokens.ts @@ -13,7 +13,7 @@ const common = { export const licenseSchema = z .object({ ...common, - v: z.literal(4), + v: z.literal(1), kind: z.literal("license"), licenseId: id, plan: id, @@ -25,32 +25,30 @@ export const licenseSchema = z subject: z.object({ kind: z.enum(["installation", "service"]), id }).strict(), offlineGraceUntil: date, status: z.enum(["active", "revoked", "expired"]), + accountId: id.nullable(), + authorizationRevision: z.number().int().positive().safe(), + paidImportsGranted: z.number().int().nonnegative().safe().nullable(), + recoveryGeneration: z.number().int().nonnegative().safe(), + trialConverted: z.literal(true), }) .strict() - .refine((p) => p.subject.id === p.installationId); + .refine((p) => p.subject.id === p.installationId) + .refine((p) => + p.accountId === null + ? p.paidImportsGranted === null && p.recoveryGeneration === 0 + : p.paidImportsGranted !== null && p.subject.kind === "installation", + ); export const trialSchema = z .object({ ...common, - v: z.literal(2), + v: z.literal(1), kind: z.literal("trial"), verdict: z.enum(["fresh", "spent"]), allowance: z.number().int().nonnegative().nullable(), supportReference: z.string().nullable(), + converted: z.boolean(), }) .strict(); -export const importLicenseSchema = z - .object({ - ...licenseSchema.shape, - v: z.literal(5), - accountId: id, - authorizationRevision: z.number().int().positive().safe(), - paidImportsGranted: z.number().int().nonnegative().safe(), - recoveryGeneration: z.number().int().nonnegative().safe(), - trialConverted: z.literal(true), - }) - .strict() - .refine((p) => p.subject.id === p.installationId); -export const importTrialSchema = trialSchema.extend({ v: z.literal(3), converted: z.boolean() }); export const recoverySchema = z .object({ ...common, @@ -72,8 +70,8 @@ export const recoverySchema = z }) .strict(); export type RecoveryToken = z.infer; -export type LicenseToken = z.infer | z.infer; -export type TrialToken = z.infer | z.infer; +export type LicenseToken = z.infer; +export type TrialToken = z.infer; export type SignedToken = LicenseToken | TrialToken; export class TokenError extends Error { @@ -128,9 +126,7 @@ async function verifyAnyToken( } catch { throw new TokenError("malformed_token"); } - const parsed = z - .union([licenseSchema, trialSchema, importLicenseSchema, importTrialSchema, recoverySchema]) - .safeParse(input); + const parsed = z.union([licenseSchema, trialSchema, recoverySchema]).safeParse(input); if (!parsed.success) throw new TokenError("invalid_payload"); const p = parsed.data; if ( diff --git a/packages/keyforge-client/src/types.ts b/packages/keyforge-client/src/types.ts index d0f134e..7f979bd 100644 --- a/packages/keyforge-client/src/types.ts +++ b/packages/keyforge-client/src/types.ts @@ -41,6 +41,7 @@ export interface RegistrationMetadata { machineSignals?: DeviceSignal[]; } export interface ClientState { + format: "keyforge-client-state"; version: 1; serverUrl: string; productId: string; @@ -50,15 +51,14 @@ export interface ClientState { registration: RegistrationMetadata & { installationId: string; requestId: string }; registered: boolean; trialStarted: boolean; - trialConsumed: number; trialToken: string | null; licenseToken: string | null; denial: string | null; lastObservedTime: number; deviceSignals?: DeviceSignalsReport; - storageRevision?: number; - importUsage?: ImportUsage | null; - pendingRecovery?: { + storageRevision: number; + importUsage: ImportUsage; + pendingRecovery: { requestId: string; licenseId: string; challenge: string; @@ -67,13 +67,12 @@ export interface ClientState { id?: string; } | null; } -/** One owner per installation. load returns a snapshot; save atomically replaces it. +/** One owner per installation. load returns a snapshot; compareAndSwap atomically replaces it. * Production implementations must protect credentials using platform secure storage. */ export interface SecureStorage { load(): Promise; - save(state: ClientState): Promise; /** Must be a database transaction or equivalent atomic operation, across all processes. */ - compareAndSwap?(expectedRevision: number | null, state: ClientState): Promise; + compareAndSwap(expectedRevision: number | null, state: ClientState): Promise; } export type Authorization = | { state: "unlicensed"; reason: string } diff --git a/packages/keyforge-client/src/usage.ts b/packages/keyforge-client/src/usage.ts index a6bacbb..c6ae574 100644 --- a/packages/keyforge-client/src/usage.ts +++ b/packages/keyforge-client/src/usage.ts @@ -12,8 +12,6 @@ export const importUsageSchema = z lifetimeKnown: z.boolean(), integrity: z.boolean(), trialConverted: z.boolean(), - legacyPaidSeen: z.boolean().default(false), - legacyVerified: z.boolean().default(false), authorizationRevision: count, recoveryGeneration: count, granted: count, @@ -43,8 +41,6 @@ export const emptyImportUsage = (): ImportUsage => ({ lifetimeKnown: true, integrity: true, trialConverted: false, - legacyPaidSeen: false, - legacyVerified: false, authorizationRevision: 0, recoveryGeneration: 0, granted: 0, @@ -70,8 +66,6 @@ export function recoveryUsageDigest(challenge: string, v: ImportUsage) { v.lifetimeKnown, v.integrity, v.trialConverted, - v.legacyPaidSeen, - v.legacyVerified, v.authorizationRevision, v.recoveryGeneration, v.granted, diff --git a/scripts/generate-client-fixtures.ts b/scripts/generate-client-fixtures.ts new file mode 100644 index 0000000..5e6e971 --- /dev/null +++ b/scripts/generate-client-fixtures.ts @@ -0,0 +1,132 @@ +import { createHash, createPrivateKey, createPublicKey, sign } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { z } from "zod"; + +import { + licenseTokenSchema, + trialTokenSchema, + recoveryTokenSchema, +} from "../src/token-contract.js"; + +// Public reproducible test seed. Never use this key for a deployment. +const key = createPrivateKey({ + key: Buffer.concat([ + Buffer.from("302e020100300506032b657004220420", "hex"), + Buffer.alloc(32, 73), + ]), + format: "der", + type: "pkcs8", +}); +const publicKeyPem = createPublicKey(key).export({ format: "pem", type: "spki" }).toString(); +const common = { + iss: "default", + productId: "fixture-product", + kid: "test-only-v1", + installationId: "11111111-1111-4111-8111-111111111111", + issuedAt: "2030-01-01T00:00:00.000Z", + expiresAt: null, +}; +const license = licenseTokenSchema.parse({ + ...common, + v: 1, + kind: "license", + licenseId: "fixture-license", + plan: "standard", + entitlements: { imports: 50000 }, + issuedTo: "Test studio", + subject: { kind: "installation", id: common.installationId }, + offlineGraceUntil: "2030-01-31T00:00:00.000Z", + status: "active", + accountId: "fixture-account", + authorizationRevision: 2, + paidImportsGranted: 50000, + recoveryGeneration: 0, + trialConverted: true, +}); +const trial = trialTokenSchema.parse({ + ...common, + v: 1, + kind: "trial", + verdict: "fresh", + allowance: 20000, + supportReference: null, + converted: false, +}); +const recovery = recoveryTokenSchema.parse({ + ...common, + v: 1, + kind: "usage-recovery", + recoveryId: "22222222-2222-4222-8222-222222222222", + accountId: "fixture-account", + challenge: Buffer.alloc(32, 17).toString("base64url"), + mode: "lost_history", + usageDigest: null, + paidImportsFloor: 50000, + trialImportsFloor: 20000, + recoveryGeneration: 1, + authorizationRevision: 3, + expiresAt: "2030-01-02T00:00:00.000Z", +}); +const token = (payload: unknown) => { + const b = Buffer.from(JSON.stringify(payload)); + return b.toString("base64url") + "." + sign(null, b, key).toString("base64url"); +}; +const samples = [ + { name: "paid-import-license", payload: license }, + { + name: "ordinary-license", + payload: licenseTokenSchema.parse({ ...license, accountId: null, paidImportsGranted: null }), + }, + { name: "revoked-license", payload: { ...license, status: "revoked" } }, + { name: "trial", payload: trial }, + { + name: "converted-trial", + payload: { ...trial, converted: true, verdict: "spent", allowance: 0 }, + }, + { name: "usage-recovery", payload: recovery }, +]; +const outputs: Record = { + "fixtures.json": + JSON.stringify( + { + testOnly: true, + context: { + productId: common.productId, + installationId: common.installationId, + now: Date.parse("2030-01-01T12:00:00.000Z"), + }, + publicKeys: { [common.kid]: publicKeyPem }, + samples: samples.map((s) => ({ ...s, token: token(s.payload) })), + }, + null, + 2, + ) + "\n", + "license-v1.schema.json": JSON.stringify(z.toJSONSchema(licenseTokenSchema), null, 2) + "\n", + "trial-v1.schema.json": JSON.stringify(z.toJSONSchema(trialTokenSchema), null, 2) + "\n", + "recovery-v1.schema.json": JSON.stringify(z.toJSONSchema(recoveryTokenSchema), null, 2) + "\n", +}; +outputs["manifest.json"] = + JSON.stringify( + { + contract: "keyforge-v1", + testOnly: true, + sha256: Object.fromEntries( + Object.entries(outputs).map(([name, body]) => [ + name, + createHash("sha256").update(body).digest("hex"), + ]), + ), + }, + null, + 2, + ) + "\n"; +const directory = resolve("docs/examples/client-contract"); +mkdirSync(directory, { recursive: true }); +for (const [name, body] of Object.entries(outputs)) { + const target = resolve(directory, name); + if (process.argv.includes("--check")) { + if (readFileSync(target, "utf8") !== body) throw Error(`Regenerate client fixture ${name}`); + } else writeFileSync(target, body); +} diff --git a/scripts/generate-client-openapi.ts b/scripts/generate-client-openapi.ts index 6718dd9..35c240c 100644 --- a/scripts/generate-client-openapi.ts +++ b/scripts/generate-client-openapi.ts @@ -10,8 +10,6 @@ import { supportRequestBodySchema } from "../src/support.js"; import { licenseTokenSchema, trialTokenSchema, - importLicenseTokenSchema, - importTrialTokenSchema, recoveryTokenSchema, } from "../src/token-contract.js"; @@ -135,8 +133,6 @@ const supportProperties = supportBody.properties as Record; const schemas: Record = { LicenseToken: fromZod(licenseTokenSchema), TrialToken: fromZod(trialTokenSchema), - ImportLicenseToken: fromZod(importLicenseTokenSchema), - ImportTrialToken: fromZod(importTrialTokenSchema), UsageRecoveryToken: fromZod(recoveryTokenSchema), Error: object( { error: string(), message: string(), retryable: boolean, serverTime: timestamp }, @@ -153,7 +149,7 @@ const schemas: Record = { userEmail: string({ maxLength: 200 }), machineSignals: { description: - "Legacy registration accepts any JSON and normalizes recognized kind/lowercase SHA-256 pairs, dropping invalid entries and duplicate pairs. Recommended clients send at most three typed signals; unlike backfill this route does not enforce that count.", + "Registration accepts any JSON and normalizes recognized kind/lowercase SHA-256 pairs, dropping invalid entries and duplicate pairs. Recommended clients send at most three typed signals; unlike backfill this route does not enforce that count.", }, }, ["installationId", "requestId"], @@ -272,8 +268,9 @@ operation( object({ ok: { const: true }, serverTime: timestamp, - tokenVersions: object({ min: { const: 4 }, max: { const: 5 } }), - trialTokenVersions: object({ min: { const: 2 }, max: { const: 3 } }), + tokenVersions: object({ min: { const: 1 }, max: { const: 1 } }), + trialTokenVersions: object({ min: { const: 1 }, max: { const: 1 } }), + recoveryTokenVersions: object({ min: { const: 1 }, max: { const: 1 } }), }), undefined, 200, @@ -328,7 +325,7 @@ operation( "post", product + "/activate", "activateLicense", - "Online activation binds an installation seat and returns a signed v4 token. Repeating the same key/identity reuses the binding. Verify and atomically persist the grant before enabling features.", + "Online activation binds an installation seat and returns a signed v1 token. Repeating the same key/identity reuses the binding. Verify and atomically persist the grant before enabling features.", ref("TokenResponse"), ref("KeyOperation"), ); @@ -382,7 +379,7 @@ operation( "post", installation + "/recovery-requests/{id}/claim", "claimUsageRecovery", - "Claims an approved, unexpired recovery once on the server. Exact retries are safe while the generation remains current. Verify the separate recovery signature, challenge, account, generation and candidate commitment before atomically adopting it with the v5 license. Approval expires after 24 hours.", + "Claims an approved, unexpired recovery once on the server. Exact retries are safe while the generation remains current. Verify the separate recovery signature, challenge, account, generation and candidate commitment before atomically adopting it with the v1 license. Approval expires after 24 hours.", object({ recoveryToken: string(), token: string(), serverTime: timestamp }), ); operation( @@ -521,7 +518,7 @@ export const clientOpenApi = { title: "KeyForge dependent-app API", version: "1.0.0", description: - "Current product-scoped API. Tokens are two base64url segments (not JWTs); Ed25519 signs the decoded UTF-8 JSON bytes. License v4 and trial v2 payloads are documented in components. Import policy opt-in adds license v5, trial v3 and separate usage-recovery v1; legacy v4/v2 remain supported. Full contextual, time and status checks are required. Private/admin routes are deliberately excluded.", + "Current product-scoped API. Tokens are two base64url segments (not JWTs); Ed25519 signs the decoded UTF-8 JSON bytes. License, trial and separate usage-recovery payloads use only v1. Import-account fields are explicitly nullable for products without import quotas; no older token format is accepted. Full contextual, time and status checks are required. Private/admin routes are deliberately excluded.", }, servers: [ { diff --git a/scripts/license-tool.mjs b/scripts/license-tool.mjs index 3f7b1c2..fd85c8a 100644 --- a/scripts/license-tool.mjs +++ b/scripts/license-tool.mjs @@ -101,7 +101,6 @@ function usage() { vp run license imports-enable --entitlement imports --revision N vp run license imports-account - vp run license imports-adopt --granted N --revision N --reason TEXT vp run license renew --plan ID --mode renewal|top_up|extension --request-id UUID Renewal extends time and adds units once; top_up adds units only; extension changes time only. Optional --paid --method TEXT --reference TEXT. @@ -343,23 +342,6 @@ switch (command) { ); break; } - case "imports-adopt": { - const requestId = flag("request-id") || randomUUID(); - console.log("Operation ID (reuse unchanged on retry): " + requestId); - await admin(`/admin/licenses/${encodeURIComponent(rest[0])}/import-account`, { - method: "POST", - body: JSON.stringify({ - requestId, - revision: Number(flag("revision")), - granted: Number(flag("granted")), - reason: flag("reason"), - }), - }); - console.log( - "Legacy license adopted. The client must migrate verified surviving usage before using v5 grants.", - ); - break; - } case "renew": { const requestId = flag("request-id") || randomUUID(); console.log("Purchase ID (reuse unchanged on retry): " + requestId); diff --git a/src/app.ts b/src/app.ts index 02ba927..96c41cb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -127,8 +127,9 @@ export async function buildLicenseApp(options: BuildLicenseAppOptions): Promise< app.get("/healthz", async () => ({ ok: true, serverTime: now().toISOString(), - tokenVersions: { min: 4, max: 5 }, - trialTokenVersions: { min: 2, max: 3 }, + tokenVersions: { min: 1, max: 1 }, + trialTokenVersions: { min: 1, max: 1 }, + recoveryTokenVersions: { min: 1, max: 1 }, })); const meta = async () => ({ name: config.branding.name, serverTime: now().toISOString() }); app.get("/v1/meta", meta); diff --git a/src/auth.ts b/src/auth.ts index 5ca52ed..1fed79f 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -130,18 +130,8 @@ export function createAuth(options: CreateAuthOptions) { export type AuthInstance = ReturnType; -/** - * Create or update Better Auth's tables, then make sure every operator has a - * role. - * - * The admin plugin adds a `role` column whose default is the lesser role, so a - * database written before roles existed would come back with its operators - * demoted — locking the vendor out of their own console on the first upgrade. - * Those operators had full access by definition (there was nothing else), so - * they are restored to `admin`. Idempotent: only null/empty roles are touched. - */ -export async function ensureAuthSchema(auth: AuthInstance): Promise { - await migrateLegacyPostgresAccountIssuer(auth); +/** Create and validate the current operator schema for a fresh setup. */ +export async function ensureAuthSchema(auth: AuthInstance): Promise { const { runMigrations } = await getMigrations(auth.options); await runMigrations(); const checked = await getMigrations(auth.options); @@ -155,7 +145,6 @@ export async function ensureAuthSchema(auth: AuthInstance): Promise { "Operator schema validation failed after migrations. " + [...checked.schemaProblems, ...checked.unsafeChanges].join(" "), ); - const promoted = await backfillOperatorRoles(auth); const handle = authHandles.get(auth)!; const statement = `CREATE TABLE IF NOT EXISTS ${BOOTSTRAP_ADMIN_TABLE} (id INTEGER PRIMARY KEY CHECK (id = 1), user_id TEXT NOT NULL)`; if ("connect" in handle) { @@ -172,60 +161,6 @@ export async function ensureAuthSchema(auth: AuthInstance): Promise { client.release(); } } else handle.exec(statement); - return promoted; -} - -/** Better Auth 1.7.0–1.7.2 required account.issuer; 1.7.3+ no longer writes it. - * Follow the upstream upgrade guide without dropping historical account data. - * The transaction lock works through managed transaction poolers too. */ -async function migrateLegacyPostgresAccountIssuer(auth: AuthInstance): Promise { - const handle = authHandles.get(auth); - if (!handle || !("connect" in handle)) return; - const client = await handle.connect(); - try { - await client.query("BEGIN"); - await client.query("SELECT pg_advisory_xact_lock(4919071115)"); - const { rows } = await client.query<{ table_schema: string }>( - "SELECT table_schema FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'account' AND column_name = 'issuer'", - ); - if (rows.length) { - const schema = '"' + rows[0].table_schema.replaceAll('"', '""') + '"'; - const duplicates = await client.query( - `SELECT 1 FROM ${schema}."account" GROUP BY "providerId", "accountId" HAVING count(*) > 1 LIMIT 1`, - ); - if (duplicates.rows.length) - throw new Error( - "Operator account provider/account keys are duplicated. Resolve those identities before upgrading Better Auth; no issuer compatibility changes were applied.", - ); - await client.query(`ALTER TABLE ${schema}."account" ALTER COLUMN "issuer" DROP NOT NULL`); - await client.query(`DROP INDEX IF EXISTS ${schema}."account_issuer_accountId_uidx"`); - } - await client.query("COMMIT"); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } -} - -async function backfillOperatorRoles(auth: AuthInstance): Promise { - const adapter = await auth.$context.then((context) => context.adapter); - const operators = (await adapter.findMany({ - model: "user", - limit: 1000, - })) as Array<{ id: string; role?: string | null }>; - let promoted = 0; - for (const operator of operators) { - if (operator.role) continue; - await adapter.update({ - model: "user", - where: [{ field: "id", value: operator.id }], - update: { role: "admin" }, - }); - promoted += 1; - } - return promoted; } export interface SeedOperatorOptions { diff --git a/src/backup-storage.ts b/src/backup-storage.ts index edd92cb..bc1d2cb 100644 --- a/src/backup-storage.ts +++ b/src/backup-storage.ts @@ -45,7 +45,6 @@ interface Current extends RecordValue { /** Provider secrets are encrypted with the external data key, never returned to the console. */ export class BackupStorage { - private migrated = false; constructor( private platform: Platform, private initial: StorageOptions, @@ -90,41 +89,6 @@ export class BackupStorage { await r.put(WORKSPACE, "backup-storage-current", { id: provider, version: id }); return id; } - async migrate() { - if (this.migrated || !this.platform.vault.ring) return; - await atomic(this.platform.options.database, async (r) => { - for (const provider of storageProvider.options) { - if (await r.get(WORKSPACE, "backup-storage-current", provider)) continue; - const config = this.initial[provider]; - if ( - !config || - ("token" in config - ? !config.token - : !config.bucket || - !config.credentials?.accessKeyId || - !config.credentials.secretAccessKey) - ) - continue; - const value: Settings = - "token" in config - ? { token: config.token } - : { - bucket: config.bucket, - region: config.region, - endpoint: config.endpoint, - ...config.credentials, - }; - const version = await this.store(r, provider, value, 0); - // Pin archives made before this migration to their original destination credentials. - for (const job of await r.list< - RecordValue & { destination?: string; storageVersion?: string } - >(WORKSPACE, "backup")) - if (job.destination === provider && !job.storageVersion) - await r.put(WORKSPACE, "backup", { ...job, storageVersion: version }); - } - }); - this.migrated = true; - } async version(provider: string, records = this.platform.records) { return (await records.get(WORKSPACE, "backup-storage-current", provider))?.version; } diff --git a/src/backups.ts b/src/backups.ts index f8eff4a..d8139ea 100644 --- a/src/backups.ts +++ b/src/backups.ts @@ -80,7 +80,6 @@ export interface Schedule extends RecordValue { retentionDays: number; destination: string; nextAt: string; - migratedTo?: string; } export const backupScheduleSchema = z .object({ @@ -130,7 +129,6 @@ export class BackupService { ); } async statuses() { - await this.storage.migrate(); const statuses = await this.destinations.statuses(await this.custom()); for (const provider of storageProvider.options) { if (!(await this.storage.version(provider))) continue; @@ -202,23 +200,7 @@ export class BackupService { } if (!this.options.remoteOnly) await mkdir(this.directory, { recursive: true, mode: 0o700 }); } - async migrateSchedules() { - await atomic(this.platform.options.database, async (r) => { - const legacy = await r.get(WORKSPACE, "backup-schedule", "default"); - if (!legacy || legacy.migratedTo) return; - if (!(await r.get(WORKSPACE, "backup-schedule", legacy.destination))) - await r.put(WORKSPACE, "backup-schedule", { - ...legacy, - id: legacy.destination, - minuteUTC: legacy.minuteUTC ?? 0, - }); - await r.put(WORKSPACE, "backup-schedule", { - ...legacy, - enabled: false, - migratedTo: legacy.destination, - }); - }); - } + async saveSchedule(destination: string, input: z.infer) { const status = (await this.statuses()).find((d) => d.id === destination); if (!status || status.archived) @@ -392,7 +374,6 @@ export class BackupService { } } async tick() { - await this.migrateSchedules(); const now = this.platform.time(); let claim: BackupJob | null = null; const statuses = await this.statuses(); @@ -751,8 +732,6 @@ export async function registerBackups( }); } }; - await service.migrateSchedules(); - await service.storage.migrate(); const publicDestination = (value: CustomDestination) => { const { encryptedHeaderValue: _secret, ...safe } = value; return { ...safe, hasAuthorization: Boolean(_secret || value.headerValueEnv) }; @@ -807,7 +786,6 @@ export async function registerBackups( handler(async () => { const destinations = await service.statuses(); const schedules = await platform.records.list(WORKSPACE, "backup-schedule"); - const legacy = await platform.records.get(WORKSPACE, "backup-schedule", "default"); return { jobs: (await platform.records.list(WORKSPACE, "backup")) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) @@ -824,9 +802,6 @@ export async function registerBackups( storageProvider.options.map(async (id) => [id, await service.storage.metadata(id)]), ), ), - schedule: legacy?.migratedTo - ? (schedules.find((s) => s.id === legacy.migratedTo) ?? null) - : (schedules.find((s) => s.id === "local") ?? null), archiveEncryption: Boolean(options.keyring), s3Configured: destinations.find((d) => d.id === "s3")?.configured ?? false, engine: platform.options.database.engine, diff --git a/src/config.ts b/src/config.ts index 4ab62b7..4bf234d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,7 +31,7 @@ const rateLimitSchema = z.object({ }); const planPresetSchema = z.object({ - /** Preset entitlements the issue form starts from; all editable per license. */ + /** Bootstrap plan entitlements; issuance uses the selected plan unchanged. */ entitlements: entitlementsSchema.prefault({}), /** Days until expiry, from issue time. Omit for perpetual. */ durationDays: z.number().int().positive().optional(), @@ -102,7 +102,7 @@ export const configSchema = z.object({ .object({ defaultPlan: z.string().min(1).default("standard"), defaultActivationLimit: z.number().int().positive().default(1), - /** Plan name -> preset. Drives the issue form; not an enforcement list. */ + /** Plan name -> initial plan definition. New licenses must select an existing plan. */ planPresets: z.record(z.string().min(1), planPresetSchema).prefault({ standard: { entitlements: {} }, }), diff --git a/src/db.ts b/src/db.ts index f1f5e49..818bf2d 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,41 +1,16 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; -import { sql, type Kysely } from "kysely"; +import type { Kysely } from "kysely"; -import { type Entitlements } from "./config.js"; +import type { Entitlements } from "./config.js"; import { connectDatabase, type ConnectOptions, type DatabaseHandle, type Engine, } from "./database.js"; -import { - newSupportReference, - normalizeSignals, - pickMatch, - trialVerdict, - MAX_GROUPS_PER_HASH, - type MachineSignal, - type SignalKind, -} from "./machines.js"; import { migrateToLatest } from "./migrations.js"; import type { Database } from "./schema.js"; -import { - newSupportRequestId, - type NormalizedSupportRequest, - type SupportCategory, - type SupportUrgency, -} from "./support.js"; - -/** - * The vendor's ledger: which keys exist, what they entitle, where each one is - * activated, which installations exist, and what operators did. This is the - * source of truth for activation, limits, transfers and revocation — and for - * nothing else. Usage (how much of an entitlement a customer has consumed) - * never comes here; the vendor's own app is authoritative for that, by design. - * - * Runs on SQLite or Postgres through Kysely; see database.ts for selection. - */ export type LicenseServerStatus = "active" | "revoked"; @@ -57,78 +32,6 @@ export interface LicenseRow { revokedAt: string | null; } -export interface ActivationRow { - licenseId: string; - installationId: string; - activatedAt: string; - deactivatedAt: string | null; -} - -export interface SupportRequestRow { - id: string; - clientRequestId: string; - licenseId: string; - installationId: string; - category: SupportCategory; - urgency: SupportUrgency; - subject: string; - message: string; - reporterName: string | null; - appVersion: string | null; - platform: string | null; - status: "queued"; - clientCreatedAt: string; - receivedAt: string; - updatedAt: string; -} - -/** Inbox rows deliberately omit the message; it is fetched only on detail. */ -export type SupportRequestSummary = Omit; - -export interface SupportRequestQuery { - category?: SupportCategory; - urgency?: SupportUrgency; - limit?: number; - offset?: number; -} - -export interface SupportRequestPage { - requests: SupportRequestSummary[]; - total: number; -} - -export type IngestSupportRequestResult = - | { outcome: "created" | "replayed"; request: SupportRequestRow } - | { outcome: "conflict" } - | { outcome: "rate_limited"; retryAfter: number }; - -class SupportQuotaExceeded extends Error { - constructor(readonly retryAfter: number) { - super("Support request quota exceeded."); - } -} - -/** - * One installed copy of the app, licensed or not. The installer registers - * every install, so this table is the census the activations table alone - * cannot be: installs vs. licenses actually issued. - */ -export interface InstallationRow { - installationId: string; - registeredAt: string; - lastSeenAt: string; - appVersion: string; - platform: string; - userName: string; - userEmail: string; - licenseId: string | null; -} - -/** - * One admin action, as it happened: who (operator email, or "cli" for the - * bearer token, which names no one), what, to which license. Append-only — - * there is deliberately no update or delete path. - */ export interface AuditRow { id: number; at: string; @@ -138,41 +41,6 @@ export interface AuditRow { detail: Record; } -export type LiveLicenseStatus = "active" | "expired" | "revoked"; - -export interface LicenseQuery { - /** Substring match against issued_to, plan, or id. */ - q?: string; - status?: LiveLicenseStatus; - /** Absent limit returns everything — the CLI's list depends on that. */ - limit?: number; - offset?: number; - now?: Date; -} - -export interface LicensePage { - licenses: LicenseRow[]; - /** Row count for the q + status scope, for pagination. */ - total: number; - /** Per-status counts over the q scope (ignoring the status filter). */ - counts: Record; -} - -export interface InstallationQuery { - /** Substring match against installation id, user name, email, or platform. */ - q?: string; - /** true = only installs holding a license; false = only unlicensed. */ - licensed?: boolean; - limit?: number; - offset?: number; -} - -export interface InstallationPage { - installations: InstallationRow[]; - total: number; - counts: { total: number; licensed: number; unlicensed: number; licensesIssued: number }; -} - export interface KeyFormat { prefix: string; alphabet: string; @@ -211,68 +79,6 @@ export function hashProductKey(key: string): string { return createHash("sha256").update(key.trim().toUpperCase()).digest("hex"); } -/** One entitlement key as it actually appears across the ledger. */ -export interface EntitlementUsage { - name: string; - /** Every JS type observed for this key. More than one is a bug worth seeing. */ - types: string[]; - licenseCount: number; - /** Licenses that are neither revoked nor expired. */ - activeLicenseCount: number; - plans: string[]; - values: { value: string; count: number }[]; - /** Distinct values beyond those listed, so a capped list never reads as whole. */ - moreValues: number; -} - -export interface EntitlementCatalog { - catalog: EntitlementUsage[]; - licensesScanned: number; -} - -/** One machine in the trial ledger, as the support console shows it. */ -export interface MachineGroupRow { - id: string; - supportReference: string; - firstRegisteredAt: string; - lastRegisteredAt: string; - registrationCount: number; - recheckCount: number; - trialSpentReported: boolean; - clearedAt: string | null; - /** - * What a registration matching this group would be told *right now*. - * - * Derived, not stored — the verdict only exists at registration time. The - * console needs it because an operator who has just cleared a machine has no - * other way to see that the clear landed, or that the customer has since used - * it up. - */ - trialState: "fresh" | "spent"; - installationIds: string[]; - signalKinds: SignalKind[]; -} - -export interface MachineQuery { - /** Substring match against support reference, group id, or installation id. */ - q?: string; - limit?: number; - offset?: number; -} - -export interface MachinePage { - machines: MachineGroupRow[]; - total: number; -} - -/** What a registration learned about the machine that made it. */ -export interface TrialRegistration { - group: MachineGroupRow; - verdict: "fresh" | "spent"; - /** False when the group was created by this very call. */ - matched: boolean; -} - function parseJson(raw: string | null, fallback: T): T { if (!raw) return fallback; try { @@ -284,75 +90,6 @@ function parseJson(raw: string | null, fallback: T): T { } } -/** Postgres returns bigint counts as strings; SQLite returns numbers. */ -function toCount(value: unknown): number { - return Number(value ?? 0); -} - -function mapSupportRequest(row: Database["support_requests"]): SupportRequestRow { - return { - id: row.id, - clientRequestId: row.client_request_id, - licenseId: row.license_id, - installationId: row.installation_id, - category: row.category as SupportCategory, - urgency: row.urgency as SupportUrgency, - subject: row.subject, - message: row.message, - reporterName: row.reporter_name, - appVersion: row.app_version, - platform: row.platform, - status: "queued", - clientCreatedAt: row.client_created_at, - receivedAt: row.received_at, - updatedAt: row.updated_at, - }; -} - -function mapSupportRequestSummary( - row: Omit, -): SupportRequestSummary { - return { - id: row.id, - clientRequestId: row.client_request_id, - licenseId: row.license_id, - installationId: row.installation_id, - category: row.category as SupportCategory, - urgency: row.urgency as SupportUrgency, - subject: row.subject, - reporterName: row.reporter_name, - appVersion: row.app_version, - platform: row.platform, - status: "queued", - clientCreatedAt: row.client_created_at, - receivedAt: row.received_at, - updatedAt: row.updated_at, - }; -} - -function sameSupportContent(row: SupportRequestRow, request: NormalizedSupportRequest): boolean { - return ( - row.category === request.category && - row.urgency === request.urgency && - row.subject === request.subject && - row.message === request.message && - row.reporterName === request.reporterName && - row.appVersion === request.appVersion && - row.platform === request.platform && - row.clientCreatedAt === request.clientCreatedAt - ); -} - -/** - * Case-insensitive substring match that means the same thing on both engines. - * SQLite's LIKE ignores ASCII case, Postgres' does not — searching "acme" - * would silently find nothing on Postgres — so both sides are lowered here - * rather than relying on either engine's default. - */ -function matches(eb: any, column: string, needle: string) { - return eb(sql`lower(${sql.ref(column)})`, "like", `%${needle.toLowerCase()}%`); -} - export class LicenseDatabase { private readonly handle: DatabaseHandle; @@ -366,8 +103,13 @@ export class LicenseDatabase { */ static async open(options: ConnectOptions = {}): Promise { const handle = connectDatabase(options); - await migrateToLatest(handle.db, handle.engine); - return new LicenseDatabase(handle); + try { + await migrateToLatest(handle.db, handle.engine); + return new LicenseDatabase(handle); + } catch (error) { + await handle.close(); + throw error; + } } get db(): Kysely { @@ -382,574 +124,6 @@ export class LicenseDatabase { return this.handle.engine; } - /** Live status computed in SQL, so filtering and counting stay in the database. */ - private liveStatus(nowIso: string) { - return sql`CASE - WHEN status = 'revoked' THEN 'revoked' - WHEN expires_at IS NOT NULL AND expires_at < ${nowIso} THEN 'expired' - ELSE 'active' - END`; - } - - private toLicense(row: { - id: string; - key_hash: string; - plan: string; - entitlements: string; - issued_to: string; - status: string; - activation_limit: number; - issued_at: string; - expires_at: string | null; - revoked_at: string | null; - }): LicenseRow { - return { - id: row.id, - keyHash: row.key_hash, - plan: row.plan, - entitlements: parseJson(row.entitlements, {}), - issuedTo: row.issued_to, - status: row.status as LicenseServerStatus, - activationLimit: Number(row.activation_limit), - issuedAt: row.issued_at, - expiresAt: row.expires_at, - revokedAt: row.revoked_at, - }; - } - - async createLicense(input: { - plan: string; - entitlements?: Entitlements; - issuedTo?: string; - activationLimit?: number; - expiresAt?: string | null; - keyFormat?: KeyFormat; - }): Promise<{ license: LicenseRow; key: string }> { - const key = generateProductKey(input.keyFormat); - const id = randomUUID(); - await this.db - .insertInto("licenses") - .values({ - id, - key_hash: hashProductKey(key), - plan: input.plan, - entitlements: JSON.stringify(input.entitlements ?? {}), - issued_to: input.issuedTo ?? "", - status: "active", - activation_limit: input.activationLimit ?? 1, - issued_at: new Date().toISOString(), - expires_at: input.expiresAt ?? null, - revoked_at: null, - }) - .execute(); - const license = await this.getLicense(id); - if (!license) throw new Error("License insert did not persist"); - return { license, key }; - } - - async getLicense(id: string): Promise { - const row = await this.db - .selectFrom("licenses") - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - return row ? this.toLicense(row) : null; - } - - async findByKey(key: string): Promise { - const row = await this.db - .selectFrom("licenses") - .selectAll() - .where("key_hash", "=", hashProductKey(key)) - .executeTakeFirst(); - return row ? this.toLicense(row) : null; - } - - async listLicenses(query: LicenseQuery = {}): Promise { - const nowIso = (query.now ?? new Date()).toISOString(); - const needle = query.q?.trim() || null; - const search = (eb: any) => - eb.or([ - matches(eb, "issued_to", needle as string), - matches(eb, "plan", needle as string), - matches(eb, "id", needle as string), - ]); - - // Counts cover the search scope but ignore the status filter, so the - // console's summary cards stay stable while the user switches tabs. - let countQuery = this.db - .selectFrom("licenses") - .select((eb) => [this.liveStatus(nowIso).as("live_status"), eb.fn.countAll().as("n")]) - .groupBy("live_status") as any; - if (needle) countQuery = countQuery.where(search); - const counts: Record = { active: 0, expired: 0, revoked: 0 }; - for (const row of (await countQuery.execute()) as Array<{ - live_status: LiveLicenseStatus; - n: unknown; - }>) { - counts[row.live_status] = toCount(row.n); - } - - let rowsQuery = this.db.selectFrom("licenses").selectAll() as any; - if (needle) rowsQuery = rowsQuery.where(search); - if (query.status) rowsQuery = rowsQuery.where(this.liveStatus(nowIso), "=", query.status); - rowsQuery = rowsQuery.orderBy("issued_at", "desc"); - if (query.limit !== undefined) { - rowsQuery = rowsQuery.limit(query.limit).offset(query.offset ?? 0); - } - const rows = await rowsQuery.execute(); - - let total = counts.active + counts.expired + counts.revoked; - if (query.status) { - let totalQuery = this.db - .selectFrom("licenses") - .select((eb) => eb.fn.countAll().as("n")) as any; - if (needle) totalQuery = totalQuery.where(search); - totalQuery = totalQuery.where(this.liveStatus(nowIso), "=", query.status); - total = toCount((await totalQuery.executeTakeFirst())?.n); - } - - return { - licenses: (rows as Parameters[0][]).map((row) => - this.toLicense(row), - ), - total, - counts, - }; - } - - /** - * Support edits: transfer to a new name, extend or clear expiry, change the - * seat limit, adjust entitlements. A field left undefined stays as it is; - * `expiresAt: null` clears the expiry. Status and the key hash are - * deliberately not editable — revocation has its own verb, and a key can - * never be re-pointed. - */ - async updateLicense( - id: string, - input: { - issuedTo?: string; - activationLimit?: number; - expiresAt?: string | null; - entitlements?: Entitlements; - }, - ): Promise { - const patch: Record = {}; - if (input.issuedTo !== undefined) patch.issued_to = input.issuedTo; - if (input.activationLimit !== undefined) patch.activation_limit = input.activationLimit; - if (input.expiresAt !== undefined) patch.expires_at = input.expiresAt; - if (input.entitlements !== undefined) patch.entitlements = JSON.stringify(input.entitlements); - if (Object.keys(patch).length > 0) { - await this.db - .updateTable("licenses") - .set(patch as never) - .where("id", "=", id) - .execute(); - } - return this.getLicense(id); - } - - async revokeLicense(id: string): Promise { - await this.db - .updateTable("licenses") - .set({ status: "revoked", revoked_at: new Date().toISOString() }) - .where("id", "=", id) - .execute(); - return this.getLicense(id); - } - - async listActivations(licenseId: string): Promise { - const rows = await this.db - .selectFrom("activations") - .selectAll() - .where("license_id", "=", licenseId) - .orderBy("activated_at") - .execute(); - return rows.map((row) => ({ - licenseId: row.license_id, - installationId: row.installation_id, - activatedAt: row.activated_at, - deactivatedAt: row.deactivated_at, - })); - } - - async activeActivationCount(licenseId: string): Promise { - const row = await this.db - .selectFrom("activations") - .select((eb) => eb.fn.countAll().as("n")) - .where("license_id", "=", licenseId) - .where("deactivated_at", "is", null) - .executeTakeFirst(); - return toCount(row?.n); - } - - async getActivation(licenseId: string, installationId: string): Promise { - const row = await this.db - .selectFrom("activations") - .selectAll() - .where("license_id", "=", licenseId) - .where("installation_id", "=", installationId) - .executeTakeFirst(); - if (!row) return null; - return { - licenseId: row.license_id, - installationId: row.installation_id, - activatedAt: row.activated_at, - deactivatedAt: row.deactivated_at, - }; - } - - /** - * Insert one support request and charge its fixed-window quota atomically. - * Exact or conflicting duplicates never touch the quota, including when two - * instances race on the same client UUID. - */ - async ingestSupportRequest(input: { - licenseId: string; - installationId: string; - request: NormalizedSupportRequest; - now: Date; - maxRequests: number; - windowMs: number; - }): Promise { - const windowStart = new Date(Math.floor(input.now.getTime() / input.windowMs) * input.windowMs); - const retryAfter = Math.max( - 1, - Math.ceil((windowStart.getTime() + input.windowMs - input.now.getTime()) / 1_000), - ); - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - return await this.db.transaction().execute(async (trx) => { - const findExisting = async () => { - const row = await trx - .selectFrom("support_requests") - .selectAll() - .where("license_id", "=", input.licenseId) - .where("installation_id", "=", input.installationId) - .where("client_request_id", "=", input.request.clientRequestId) - .executeTakeFirst(); - return row ? mapSupportRequest(row) : null; - }; - - const receivedAt = input.now.toISOString(); - const inserted = await trx - .insertInto("support_requests") - .values({ - id: newSupportRequestId(input.now.getTime()), - client_request_id: input.request.clientRequestId, - license_id: input.licenseId, - installation_id: input.installationId, - category: input.request.category, - urgency: input.request.urgency, - subject: input.request.subject, - message: input.request.message, - reporter_name: input.request.reporterName, - app_version: input.request.appVersion, - platform: input.request.platform, - status: "queued", - client_created_at: input.request.clientCreatedAt, - received_at: receivedAt, - updated_at: receivedAt, - }) - .onConflict((conflict) => - conflict.columns(["license_id", "installation_id", "client_request_id"]).doNothing(), - ) - .returningAll() - .executeTakeFirst(); - - if (!inserted) { - const raced = await findExisting(); - if (!raced || !sameSupportContent(raced, input.request)) { - return { outcome: "conflict" as const }; - } - return { outcome: "replayed" as const, request: raced }; - } - - const bucket = await sql<{ request_count: number | string }>` - INSERT INTO support_rate_limit_buckets - (license_id, installation_id, window_start, request_count) - VALUES ( - ${input.licenseId}, ${input.installationId}, - ${windowStart.toISOString()}, 1 - ) - ON CONFLICT (license_id, installation_id, window_start) - DO UPDATE SET request_count = support_rate_limit_buckets.request_count + 1 - WHERE support_rate_limit_buckets.request_count < ${input.maxRequests} - RETURNING request_count - `.execute(trx); - if (bucket.rows.length === 0) throw new SupportQuotaExceeded(retryAfter); - return { outcome: "created" as const, request: mapSupportRequest(inserted) }; - }); - } catch (error) { - if (error instanceof SupportQuotaExceeded) { - return { outcome: "rate_limited", retryAfter: error.retryAfter }; - } - const code = - typeof error === "object" && error !== null && "code" in error ? String(error.code) : ""; - if (this.engine === "sqlite" && code === "SQLITE_BUSY" && attempt < 2) { - // Two processes can begin deferred SQLite transactions together and - // deadlock while upgrading to the writer lock. One rolls back; a - // short whole-transaction retry then observes the committed row. - await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1))); - continue; - } - throw error; - } - } - throw new Error("Support ingestion retry budget exhausted."); - } - - /** Operator inbox. Message bodies stay out of list reads and list responses. */ - async listSupportRequests(query: SupportRequestQuery = {}): Promise { - const filters = (qb: any) => { - let filtered = qb; - if (query.category) filtered = filtered.where("category", "=", query.category); - if (query.urgency) filtered = filtered.where("urgency", "=", query.urgency); - return filtered; - }; - - const totalQuery = filters( - this.db.selectFrom("support_requests").select((eb) => eb.fn.countAll().as("n")), - ); - const total = toCount((await totalQuery.executeTakeFirst())?.n); - - let rowsQuery = filters( - this.db - .selectFrom("support_requests") - .select([ - "id", - "client_request_id", - "license_id", - "installation_id", - "category", - "urgency", - "subject", - "reporter_name", - "app_version", - "platform", - "status", - "client_created_at", - "received_at", - "updated_at", - ]), - ).orderBy("received_at", "desc"); - if (query.limit !== undefined) { - rowsQuery = rowsQuery.limit(query.limit).offset(query.offset ?? 0); - } - const rows = await rowsQuery.execute(); - return { - requests: rows.map((row: Omit) => - mapSupportRequestSummary(row), - ), - total, - }; - } - - async getSupportRequest(id: string): Promise { - const row = await this.db - .selectFrom("support_requests") - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - return row ? mapSupportRequest(row) : null; - } - - /** Insert a fresh binding, or clear the deactivation on a returning one. */ - async bindActivation(licenseId: string, installationId: string): Promise { - const now = new Date().toISOString(); - await this.db - .insertInto("activations") - .values({ - license_id: licenseId, - installation_id: installationId, - activated_at: now, - deactivated_at: null, - }) - .onConflict((oc) => - oc.columns(["license_id", "installation_id"]).doUpdateSet({ - activated_at: now, - deactivated_at: null, - }), - ) - .execute(); - } - - async releaseActivation(licenseId: string, installationId: string): Promise { - await this.db - .updateTable("activations") - .set({ deactivated_at: new Date().toISOString() }) - .where("license_id", "=", licenseId) - .where("installation_id", "=", installationId) - .where("deactivated_at", "is", null) - .execute(); - } - - private toInstallation(row: { - installation_id: string; - registered_at: string; - last_seen_at: string; - app_version: string; - platform: string; - user_name: string; - user_email: string; - license_id: string | null; - }): InstallationRow { - return { - installationId: row.installation_id, - registeredAt: row.registered_at, - lastSeenAt: row.last_seen_at, - appVersion: row.app_version, - platform: row.platform, - userName: row.user_name, - userEmail: row.user_email, - licenseId: row.license_id, - }; - } - - /** - * Insert a fresh install, or refresh a returning one. Re-registration (a - * reinstall) updates whatever the installer sent and bumps last_seen, but an - * empty field never erases what an earlier registration reported. - */ - async registerInstallation(input: { - installationId: string; - appVersion?: string; - platform?: string; - userName?: string; - userEmail?: string; - }): Promise { - const now = new Date().toISOString(); - const keep = (column: string, incoming: string) => - incoming === "" ? sql.raw(column) : sql`${incoming}`; - await this.db - .insertInto("installations") - .values({ - installation_id: input.installationId, - registered_at: now, - last_seen_at: now, - app_version: input.appVersion ?? "", - platform: input.platform ?? "", - user_name: input.userName ?? "", - user_email: input.userEmail ?? "", - license_id: null, - }) - .onConflict((oc) => - oc.column("installation_id").doUpdateSet({ - last_seen_at: now, - app_version: keep("installations.app_version", input.appVersion ?? "") as never, - platform: keep("installations.platform", input.platform ?? "") as never, - user_name: keep("installations.user_name", input.userName ?? "") as never, - user_email: keep("installations.user_email", input.userEmail ?? "") as never, - }), - ) - .execute(); - const row = await this.db - .selectFrom("installations") - .selectAll() - .where("installation_id", "=", input.installationId) - .executeTakeFirstOrThrow(); - return this.toInstallation(row); - } - - /** - * Called on activation. An installation that skipped the installer's - * registration (an old build, a blocked request) still earns a census row - * here rather than staying invisible. - */ - async linkInstallationToLicense(installationId: string, licenseId: string): Promise { - const now = new Date().toISOString(); - await this.db - .insertInto("installations") - .values({ - installation_id: installationId, - registered_at: now, - last_seen_at: now, - app_version: "", - platform: "", - user_name: "", - user_email: "", - license_id: licenseId, - }) - .onConflict((oc) => - oc.column("installation_id").doUpdateSet({ license_id: licenseId, last_seen_at: now }), - ) - .execute(); - } - - /** Refresh check-ins keep last_seen honest without touching anything else. */ - async touchInstallation(installationId: string): Promise { - await this.db - .updateTable("installations") - .set({ last_seen_at: new Date().toISOString() }) - .where("installation_id", "=", installationId) - .execute(); - } - - async listInstallations(query: InstallationQuery = {}): Promise { - const needle = query.q?.trim() || null; - const search = (eb: any) => - eb.or([ - matches(eb, "installation_id", needle as string), - matches(eb, "user_name", needle as string), - matches(eb, "user_email", needle as string), - matches(eb, "platform", needle as string), - ]); - - let censusQuery = this.db - .selectFrom("installations") - .select((eb) => [ - eb.fn.countAll().as("total"), - eb.fn.sum(sql`CASE WHEN license_id IS NOT NULL THEN 1 ELSE 0 END`).as("licensed"), - ]) as any; - if (needle) censusQuery = censusQuery.where(search); - const census = await censusQuery.executeTakeFirst(); - const censusTotal = toCount(census?.total); - const licensed = toCount(census?.licensed); - - const issued = await this.db - .selectFrom("licenses") - .select((eb) => eb.fn.countAll().as("n")) - .executeTakeFirst(); - - let rowsQuery = this.db.selectFrom("installations").selectAll() as any; - if (needle) rowsQuery = rowsQuery.where(search); - if (query.licensed !== undefined) { - rowsQuery = query.licensed - ? rowsQuery.where("license_id", "is not", null) - : rowsQuery.where("license_id", "is", null); - } - rowsQuery = rowsQuery.orderBy("registered_at", "desc"); - if (query.limit !== undefined) { - rowsQuery = rowsQuery.limit(query.limit).offset(query.offset ?? 0); - } - const rows = await rowsQuery.execute(); - - let total = censusTotal; - if (query.licensed !== undefined) { - let totalQuery = this.db - .selectFrom("installations") - .select((eb) => eb.fn.countAll().as("n")) as any; - if (needle) totalQuery = totalQuery.where(search); - totalQuery = query.licensed - ? totalQuery.where("license_id", "is not", null) - : totalQuery.where("license_id", "is", null); - total = toCount((await totalQuery.executeTakeFirst())?.n); - } - - return { - installations: (rows as Parameters[0][]).map((row) => - this.toInstallation(row), - ), - total, - counts: { - total: censusTotal, - licensed, - unlicensed: censusTotal - licensed, - licensesIssued: toCount(issued?.n), - }, - }; - } - async appendAudit(entry: { actor: string; action: string; @@ -998,431 +172,6 @@ export class LicenseDatabase { })); } - /** - * Every entitlement key the ledger actually contains, with how it is used. - * - * Entitlements are free-form by design — `Record` - * — which means nothing stops `seats` on one license and `seat` or `Seats` on - * the next. The app enforcing `seats` ignores the others silently, so the - * license looks issued and the customer is missing what they paid for. This - * is the query that makes that visible. - * - * Aggregated in JS rather than SQL: SQLite's `json_each` and Postgres's - * `jsonb_each` are different functions with different semantics, and one code - * path correct on both beats two that drift — the same reasoning that keeps - * ordering out of SQL in src/replica.ts. It reads one narrow projection of - * the licenses table, not whole rows. - */ - async entitlementCatalog(options: { valueLimit?: number } = {}): Promise { - const valueLimit = options.valueLimit ?? 10; - const rows = await this.db - .selectFrom("licenses") - .select(["plan", "status", "entitlements", "expires_at"]) - .execute(); - - const now = new Date(); - const entries = new Map< - string, - { - types: Set; - licenseCount: number; - activeLicenseCount: number; - plans: Set; - values: Map; - } - >(); - - for (const row of rows) { - const entitlements = parseJson(row.entitlements as string, {}); - // "Active" here matches what the API reports, expiry included — an - // entitlement only present on expired licenses is not in play any more. - const expired = row.expires_at !== null && new Date(row.expires_at as string) < now; - const live = row.status === "active" && !expired; - - for (const [name, value] of Object.entries(entitlements)) { - let entry = entries.get(name); - if (!entry) { - entry = { - types: new Set(), - licenseCount: 0, - activeLicenseCount: 0, - plans: new Set(), - values: new Map(), - }; - entries.set(name, entry); - } - entry.types.add(typeof value); - entry.licenseCount += 1; - if (live) entry.activeLicenseCount += 1; - entry.plans.add(String(row.plan)); - const rendered = String(value); - entry.values.set(rendered, (entry.values.get(rendered) ?? 0) + 1); - } - } - - const catalog = [...entries.entries()] - .map(([name, entry]) => { - const sorted = [...entry.values.entries()].sort( - (a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0), - ); - return { - name, - types: [...entry.types].sort(), - licenseCount: entry.licenseCount, - activeLicenseCount: entry.activeLicenseCount, - plans: [...entry.plans].sort(), - values: sorted.slice(0, valueLimit).map(([value, count]) => ({ value, count })), - // Stated rather than implied: a truncated list that looks complete is - // worse than one that says how much it is hiding. - moreValues: Math.max(0, sorted.length - valueLimit), - }; - }) - // Byte-wise, so the order is identical on both engines regardless of the - // database's collation. - .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); - - return { catalog, licensesScanned: rows.length }; - } - - // ------------------------------------------------------------ trial ledger -- - - /** - * Record a registration and say whether this machine's trial is already gone. - * - * The verdict is read from the ledger's state *before* this call is counted, - * so the first registration of a machine is always `fresh` and the second is - * `spent`. Everything else here is bookkeeping for the support console. - */ - async recordTrialRegistration(input: { - installationId: string; - machineSignals?: unknown; - trialSpent?: boolean; - recheck?: boolean; - now?: Date; - }): Promise { - const nowIso = (input.now ?? new Date()).toISOString(); - const signals = normalizeSignals(input.machineSignals); - - const matchedId = signals.length - ? await this.findMatchingGroup(signals) - : await this.findGroupByInstallation(input.installationId); - // A group reached through the installation id is a filing cabinet, not a - // match: signal-less registrations are always fresh, because the client - // told us nothing that could identify the machine. - const matched = signals.length > 0 && matchedId !== null; - - const existing = matchedId ? await this.readGroupState(matchedId) : null; - const verdict = trialVerdict(matched ? existing : null); - - const groupId = matchedId ?? (await this.createGroup(nowIso)); - await this.db.transaction().execute(async (trx) => { - await trx - .updateTable("machine_groups") - .set({ - last_registered_at: nowIso, - registration_count: sql`registration_count + 1` as never, - ...(input.recheck === true ? { recheck_count: sql`recheck_count + 1` as never } : {}), - // Sticky: a client that once reported a spent trial is evidence - // forever, and a later registration that omits the flag is not a - // retraction. Support evidence only — never read for the verdict. - ...(input.trialSpent === true ? { trial_spent_reported: 1 } : {}), - }) - .where("id", "=", groupId) - .execute(); - - for (const signal of signals) { - await trx - .insertInto("machine_signals") - .values({ - group_id: groupId, - kind: signal.kind, - hash: signal.hash, - first_seen_at: nowIso, - last_seen_at: nowIso, - }) - .onConflict((oc) => - oc.columns(["group_id", "kind", "hash"]).doUpdateSet({ last_seen_at: nowIso }), - ) - .execute(); - } - - if (input.installationId) { - await trx - .insertInto("machine_installations") - .values({ - group_id: groupId, - installation_id: input.installationId, - first_seen_at: nowIso, - }) - .onConflict((oc) => oc.columns(["group_id", "installation_id"]).doNothing()) - .execute(); - } - }); - - const group = await this.getMachine(groupId); - if (!group) throw new Error("Machine group did not persist"); - return { group, verdict, matched }; - } - - /** - * The group these signals belong to, or null. - * - * Two passes on purpose. The first counts how many distinct groups each hash - * appears in and drops the ones over MAX_GROUPS_PER_HASH — an identifier - * shared by a cloned VM image or an OEM batch is not evidence of anything, - * and left in it would fuse every install that has it into one machine. The - * second scores what survives by distinct *kind*, which is what - * src/machines.ts decides on. - */ - private async findMatchingGroup(signals: MachineSignal[]): Promise { - const hashes = [...new Set(signals.map((signal) => signal.hash))]; - const rows = await this.db - .selectFrom("machine_signals") - .select(["group_id", "kind", "hash"]) - .where("hash", "in", hashes) - .execute(); - - const groupsPerHash = new Map>(); - for (const row of rows) { - const seen = groupsPerHash.get(row.hash) ?? new Set(); - seen.add(row.group_id); - groupsPerHash.set(row.hash, seen); - } - - // Only an exact (kind, hash) pair counts. The same hash arriving under a - // different kind is a client bug or a probe, not corroboration. - const wanted = new Set(signals.map((signal) => `${signal.kind}:${signal.hash}`)); - const kindsByGroup = new Map>(); - for (const row of rows) { - if ((groupsPerHash.get(row.hash)?.size ?? 0) > MAX_GROUPS_PER_HASH) continue; - if (!wanted.has(`${row.kind}:${row.hash}`)) continue; - const kinds = kindsByGroup.get(row.group_id) ?? new Set(); - kinds.add(row.kind); - kindsByGroup.set(row.group_id, kinds); - } - if (kindsByGroup.size === 0) return null; - - const ages = await this.db - .selectFrom("machine_groups") - .select(["id", "first_registered_at"]) - .where("id", "in", [...kindsByGroup.keys()]) - .execute(); - - return ( - pickMatch( - ages.map((row) => ({ - groupId: row.id, - matchingKinds: kindsByGroup.get(row.id)?.size ?? 0, - firstRegisteredAt: row.first_registered_at, - })), - )?.groupId ?? null - ); - } - - /** - * Where a signal-less registration files itself. - * - * Not a match — it never changes the verdict — but without it every - * registration from an installation that reports no signals would mint - * another group, and the support console would fill with single-use rows. - */ - private async findGroupByInstallation(installationId: string): Promise { - if (!installationId) return null; - const row = await this.db - .selectFrom("machine_installations") - .select("group_id") - .where("installation_id", "=", installationId) - .orderBy("first_seen_at", "asc") - .executeTakeFirst(); - return row?.group_id ?? null; - } - - private async readGroupState( - id: string, - ): Promise<{ clearedAtCount: number | null; registrationCount: number } | null> { - const row = await this.db - .selectFrom("machine_groups") - .select(["cleared_at_count", "registration_count"]) - .where("id", "=", id) - .executeTakeFirst(); - if (!row) return null; - return { - clearedAtCount: row.cleared_at_count === null ? null : Number(row.cleared_at_count), - registrationCount: Number(row.registration_count), - }; - } - - /** - * A new machine, with a reference short enough to read down a phone line. - * - * Six characters collide about as often as never, but "about as often as - * never" times every install is not zero, and the column is UNIQUE — so the - * insert is retried rather than trusted. - */ - private async createGroup(nowIso: string): Promise { - for (let attempt = 0; attempt < 5; attempt += 1) { - const id = `grp_${randomBytes(6).toString("hex")}`; - try { - await this.db - .insertInto("machine_groups") - .values({ - id, - support_reference: newSupportReference(), - first_registered_at: nowIso, - last_registered_at: nowIso, - registration_count: 0, - recheck_count: 0, - trial_spent_reported: 0, - cleared_at: null, - cleared_at_count: null, - }) - .execute(); - return id; - } catch (error) { - if (attempt === 4) throw error; - } - } - throw new Error("Could not allocate a machine support reference"); - } - - async getMachine(id: string): Promise { - const row = await this.db - .selectFrom("machine_groups") - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - if (!row) return null; - return (await this.decorateMachines([row]))[0]; - } - - /** - * Support's way in. `q` matches the reference the customer reads out, the - * group id an operator copied from a previous answer, or an installation id - * out of a log — whichever of the three they happen to be holding. - */ - async listMachines(query: MachineQuery = {}): Promise { - const needle = query.q?.trim() || null; - let ids: string[] | null = null; - if (needle) { - const hits = await this.db - .selectFrom("machine_installations") - .select("group_id") - .where((eb) => matches(eb, "installation_id", needle)) - .execute(); - ids = [...new Set(hits.map((hit) => hit.group_id))]; - } - const search = (eb: any) => - eb.or([ - matches(eb, "support_reference", needle as string), - matches(eb, "id", needle as string), - ...(ids && ids.length ? [eb("id", "in", ids)] : []), - ]); - - let totalQuery = this.db - .selectFrom("machine_groups") - .select((eb) => eb.fn.countAll().as("n")) as any; - if (needle) totalQuery = totalQuery.where(search); - const total = toCount((await totalQuery.executeTakeFirst())?.n); - - let rowsQuery = this.db.selectFrom("machine_groups").selectAll() as any; - if (needle) rowsQuery = rowsQuery.where(search); - rowsQuery = rowsQuery.orderBy("first_registered_at", "desc"); - if (query.limit !== undefined) { - rowsQuery = rowsQuery.limit(query.limit).offset(query.offset ?? 0); - } - return { machines: await this.decorateMachines(await rowsQuery.execute()), total }; - } - - /** - * Attach each group's installation ids and signal kinds. - * - * Two extra queries for the whole page rather than an aggregate in SQL: - * string_agg and group_concat are spelled differently on the two engines, and - * a support listing is not the place to spend a portability exception. - */ - private async decorateMachines( - rows: { - id: string; - support_reference: string; - first_registered_at: string; - last_registered_at: string; - registration_count: number; - recheck_count: number; - trial_spent_reported: number; - cleared_at: string | null; - cleared_at_count: number | null; - }[], - ): Promise { - if (rows.length === 0) return []; - const ids = rows.map((row) => row.id); - const [installations, signals] = await Promise.all([ - this.db - .selectFrom("machine_installations") - .select(["group_id", "installation_id"]) - .where("group_id", "in", ids) - .orderBy("first_seen_at", "asc") - .execute(), - this.db - .selectFrom("machine_signals") - .select(["group_id", "kind"]) - .where("group_id", "in", ids) - .execute(), - ]); - - const byGroup = new Map }>(); - for (const id of ids) byGroup.set(id, { installationIds: [], kinds: new Set() }); - for (const row of installations) - byGroup.get(row.group_id)?.installationIds.push(row.installation_id); - for (const row of signals) byGroup.get(row.group_id)?.kinds.add(row.kind); - - return rows.map((row) => ({ - id: row.id, - supportReference: row.support_reference, - firstRegisteredAt: row.first_registered_at, - lastRegisteredAt: row.last_registered_at, - registrationCount: Number(row.registration_count), - recheckCount: Number(row.recheck_count), - // SQLite hands back 0/1 and Postgres hands back 0/1 too, since the column - // is an integer on both — but the API says boolean, so it is one here. - trialSpentReported: Number(row.trial_spent_reported) !== 0, - clearedAt: row.cleared_at, - // A group with no signals was recorded from a registration that reported - // none, so nothing can ever match it — its trial is perpetually fresh, - // and saying "spent" would send support looking for a problem. - trialState: - (byGroup.get(row.id)?.kinds.size ?? 0) === 0 - ? "fresh" - : trialVerdict({ - clearedAtCount: row.cleared_at_count === null ? null : Number(row.cleared_at_count), - registrationCount: Number(row.registration_count), - }), - installationIds: byGroup.get(row.id)?.installationIds ?? [], - signalKinds: [...(byGroup.get(row.id)?.kinds ?? [])].sort() as SignalKind[], - })); - } - - /** - * Give a machine its trial back. The next registration from it reads `fresh`; - * the one after that is `spent` again — a clear is one more trial, not a - * permanent exemption. - */ - async clearMachine(id: string, now: Date = new Date()): Promise { - const existing = await this.getMachine(id); - if (!existing) return null; - await this.db - .updateTable("machine_groups") - .set({ cleared_at: now.toISOString(), cleared_at_count: existing.registrationCount }) - .where("id", "=", id) - .execute(); - return this.getMachine(id); - } - - /** - * Write a consistent snapshot to destPath via SQLite's online backup API — - * safe under WAL and concurrent writes, unlike copying the file. Postgres - * deployments back up with their own tooling (pg_dump), so this refuses - * rather than pretending. - */ async backupTo(destPath: string): Promise { if (!this.handle.sqlite) { throw new Error("Online backup is only available on SQLite deployments."); diff --git a/src/import-licensing.ts b/src/import-licensing.ts index 57af09d..44119a2 100644 --- a/src/import-licensing.ts +++ b/src/import-licensing.ts @@ -125,70 +125,6 @@ export function registerImportLicensing( }; }), ); - app.post( - ap + "/licenses/:id/import-account", - write(async (req: any) => { - const input = z - .object({ - requestId: uuid, - revision: z.number().int(), - granted: units, - reason: z.string().trim().min(10).max(1000), - }) - .strict() - .parse(req.body); - return atomic(p.options.database, async (r) => { - const pid = req.params.productId, - s = scope(pid), - grant = await license(r, pid, req.params.id); - const prior = await r.get(s, "licensing_event", input.requestId); - const fingerprint = digest({ ...input, licenseId: grant.id, actor: req.actor }); - if (prior) { - if (prior.fingerprint !== fingerprint) - throw new DomainError("idempotency_conflict", "Operation ID changed.", 409); - return { account: await importAccount(r, pid, grant) }; - } - if ( - grant.importAccountId || - grant.activationLimit !== 1 || - grant.subjectKind !== "installation" || - !(await p.product(pid, r)).importPolicy - ) - throw new DomainError( - "import_migration_required", - "Enable policy and use a single-installation legacy license without an existing account.", - 409, - ); - const bindings = (await r.list(s, "binding")).filter((b) => b.licenseId === grant.id); - if (new Set(bindings.map((b) => b.installationId)).size > 1) - throw new DomainError( - "import_migration_required", - "Multiple historical installations require explicit operator reconciliation.", - 409, - ); - const account: ImportAccount = { - id: randomUUID(), - licenseId: grant.id, - granted: input.granted, - authorizationRevision: 1, - recoveryGeneration: 0, - ownerInstallationId: bindings[0]?.installationId ?? null, - }; - await r.put(s, "import_account", account); - await r.put(s, "license", { ...grant, importAccountId: account.id }, input.revision); - await recordImportEvent(r, pid, { - id: input.requestId, - kind: "legacy.adopt", - accountId: account.id, - actor: req.actor, - fingerprint, - reason: input.reason, - createdAt: p.time(), - }); - return { account }; - }); - }), - ); app.post( ap + "/licenses/:id/renew", write(async (req: any) => { diff --git a/src/keypair.ts b/src/keypair.ts index fe86d1f..9c43603 100644 --- a/src/keypair.ts +++ b/src/keypair.ts @@ -8,7 +8,7 @@ import { readFileSync } from "node:fs"; * defers every possible mistake to the first customer activation: a key that is * RSA rather than Ed25519, a *public* key pasted where the private one belongs, * or newlines mangled by a dashboard's env editor all load without complaint and - * then throw inside `signLicenseToken`. The service answers health checks the + * then throw during token signing. The service answers health checks the * whole time. This module turns all of that into a startup failure with a * sentence attached. * diff --git a/src/migrations.ts b/src/migrations.ts index 7c77228..84610b0 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -1,304 +1,33 @@ import { sql, type Kysely } from "kysely"; -import { tableColumns, tableExists, type Engine } from "./database.js"; +import { tableExists, type Engine } from "./database.js"; import type { Database } from "./schema.js"; - -/** - * Schema migrations, run at boot. - * - * Deliberately hand-rolled rather than Kysely's file-based Migrator: the - * service ships as a single compiled bundle (and a Docker image), where a - * migrations *folder* to discover at runtime is a deployment footgun. Each - * step is idempotent and recorded in `schema_migrations`, so booting an old - * database or a fresh one both converge. - */ - interface Migration { name: string; up: (db: Kysely, engine: Engine) => Promise; } - -/** `INTEGER PRIMARY KEY AUTOINCREMENT` has no portable spelling. */ -function autoIncrementId(engine: Engine) { - return engine === "postgres" - ? sql`bigserial PRIMARY KEY` - : sql`integer PRIMARY KEY AUTOINCREMENT`; -} - const migrations: Migration[] = [ { - name: "000_platform_records", - up: async (db, engine) => { - if (!(await tableExists(db, engine, "platform_records"))) { - await db.schema - .createTable("platform_records") - .addColumn("scope", "text", (c) => c.notNull()) - .addColumn("kind", "text", (c) => c.notNull()) - .addColumn("id", "text", (c) => c.notNull()) - .addColumn("data", "text", (c) => c.notNull()) - .addColumn("revision", "integer", (c) => c.notNull()) - .addPrimaryKeyConstraint("platform_records_pk", ["scope", "kind", "id"]) - .execute(); - } - if (!(await tableExists(db, engine, "platform_lock"))) { - await db.schema - .createTable("platform_lock") - .addColumn("id", "integer", (c) => c.primaryKey()) - .addColumn("value", "integer", (c) => c.notNull()) - .execute(); - await db.insertInto("platform_lock").values({ id: 1, value: 0 }).execute(); - } - }, - }, - { - name: "001_core_tables", - up: async (db, engine) => { - if (!(await tableExists(db, engine, "licenses"))) { - await db.schema - .createTable("licenses") - .addColumn("id", "text", (column) => column.primaryKey()) - .addColumn("key_hash", "text", (column) => column.notNull().unique()) - .addColumn("plan", "text", (column) => column.notNull()) - .addColumn("entitlements", "text", (column) => column.notNull().defaultTo("{}")) - .addColumn("issued_to", "text", (column) => column.notNull().defaultTo("")) - .addColumn("status", "text", (column) => column.notNull().defaultTo("active")) - .addColumn("activation_limit", "integer", (column) => column.notNull().defaultTo(1)) - .addColumn("issued_at", "text", (column) => column.notNull()) - .addColumn("expires_at", "text") - .addColumn("revoked_at", "text") - .addCheckConstraint("licenses_status_check", sql`status IN ('active', 'revoked')`) - .execute(); - } - - if (!(await tableExists(db, engine, "activations"))) { - await db.schema - .createTable("activations") - .addColumn("license_id", "text", (column) => - column.notNull().references("licenses.id").onDelete("cascade"), - ) - .addColumn("installation_id", "text", (column) => column.notNull()) - .addColumn("activated_at", "text", (column) => column.notNull()) - .addColumn("deactivated_at", "text") - .addPrimaryKeyConstraint("activations_pkey", ["license_id", "installation_id"]) - .execute(); - } - - if (!(await tableExists(db, engine, "installations"))) { - await db.schema - .createTable("installations") - .addColumn("installation_id", "text", (column) => column.primaryKey()) - .addColumn("registered_at", "text", (column) => column.notNull()) - .addColumn("last_seen_at", "text", (column) => column.notNull()) - .addColumn("app_version", "text", (column) => column.notNull().defaultTo("")) - .addColumn("platform", "text", (column) => column.notNull().defaultTo("")) - .addColumn("user_name", "text", (column) => column.notNull().defaultTo("")) - .addColumn("user_email", "text", (column) => column.notNull().defaultTo("")) - .addColumn("license_id", "text", (column) => column.references("licenses.id")) - .execute(); - } - - if (!(await tableExists(db, engine, "audit_log"))) { - await db.schema - .createTable("audit_log") - .addColumn("id", sql`${autoIncrementId(engine)}` as never) - .addColumn("at", "text", (column) => column.notNull()) - .addColumn("actor", "text", (column) => column.notNull()) - .addColumn("action", "text", (column) => column.notNull()) - .addColumn("license_id", "text") - .addColumn("detail", "text", (column) => column.notNull().defaultTo("{}")) - .execute(); - await db.schema - .createIndex("idx_audit_log_license") - .on("audit_log") - .column("license_id") - .execute(); - } - }, - }, - { - name: "002_entitlements", - up: async (db, engine) => { - // Databases created before entitlements existed carry an `allowance` - // integer column instead. Add the new column, fold the old values in, - // and leave the legacy column alone — dropping a column a rolled-back - // deployment might still write to is not worth the risk. - const columns = await tableColumns(db, engine, "licenses"); - if (!columns.has("entitlements")) { - await db.schema - .alterTable("licenses") - .addColumn("entitlements", "text", (column) => column.notNull().defaultTo("{}")) - .execute(); - } - if (columns.has("allowance")) { - // json_set (SQLite) and jsonb juggling (Postgres) both exist, but a - // plain string build works on both and this runs once. - await sql` - UPDATE licenses - SET entitlements = '{"allowance":' || CAST(allowance AS ${sql.raw( - engine === "postgres" ? "text" : "TEXT", - )}) || '}' - WHERE entitlements = '{}' AND allowance IS NOT NULL AND allowance > 0 - `.execute(db); - } - }, - }, - { - name: "003_drop_legacy_allowance", - up: async (db, engine) => { - // The legacy column is NOT NULL with no default, so leaving it in place - // makes every new insert fail once inserts stopped mentioning it. Its - // values were folded into entitlements by 002, so dropping it loses - // nothing — and it keeps the insert path free of legacy special cases. - const columns = await tableColumns(db, engine, "licenses"); - if (columns.has("allowance")) { - await db.schema.alterTable("licenses").dropColumn("allowance").execute(); - } - }, - }, - { - name: "004_machine_ledger", - up: async (db, engine) => { - // The trial ledger. Separate from `installations` on purpose: the census - // counts installs and answers "who has this build?", while this answers - // "has this *machine* had its trial?" — one installation id can move - // between machines (a restored backup), and one machine collects many - // installation ids over its life. - if (!(await tableExists(db, engine, "machine_groups"))) { - await db.schema - .createTable("machine_groups") - .addColumn("id", "text", (column) => column.primaryKey()) - .addColumn("support_reference", "text", (column) => column.notNull().unique()) - .addColumn("first_registered_at", "text", (column) => column.notNull()) - .addColumn("last_registered_at", "text", (column) => column.notNull()) - .addColumn("registration_count", "integer", (column) => column.notNull().defaultTo(0)) - .addColumn("recheck_count", "integer", (column) => column.notNull().defaultTo(0)) - .addColumn("trial_spent_reported", "integer", (column) => column.notNull().defaultTo(0)) - .addColumn("cleared_at", "text") - .addColumn("cleared_at_count", "integer") - .execute(); - } - - if (!(await tableExists(db, engine, "machine_signals"))) { - await db.schema - .createTable("machine_signals") - .addColumn("group_id", "text", (column) => - column.notNull().references("machine_groups.id").onDelete("cascade"), - ) - .addColumn("kind", "text", (column) => column.notNull()) - .addColumn("hash", "text", (column) => column.notNull()) - .addColumn("first_seen_at", "text", (column) => column.notNull()) - .addColumn("last_seen_at", "text", (column) => column.notNull()) - .addPrimaryKeyConstraint("machine_signals_pkey", ["group_id", "kind", "hash"]) - .execute(); - // Every registration looks its hashes up across all groups; without - // this the trial check degrades into a table scan per install. - await db.schema - .createIndex("idx_machine_signals_hash") - .on("machine_signals") - .column("hash") - .execute(); - } - - if (!(await tableExists(db, engine, "machine_installations"))) { - await db.schema - .createTable("machine_installations") - .addColumn("group_id", "text", (column) => - column.notNull().references("machine_groups.id").onDelete("cascade"), - ) - .addColumn("installation_id", "text", (column) => column.notNull()) - .addColumn("first_seen_at", "text", (column) => column.notNull()) - .addPrimaryKeyConstraint("machine_installations_pkey", ["group_id", "installation_id"]) - .execute(); - await db.schema - .createIndex("idx_machine_installations_installation") - .on("machine_installations") - .column("installation_id") - .execute(); - } - }, - }, - { - name: "005_entitlements_imports", - up: async (db) => { - // `allowance` was the one entitlement the server ever named, back when it - // was a scalar column. It is now `imports`, which says what it counts, and - // the v1/v2 tokens' scalar `allowance` field is minted from it. Rows - // written before this carry the old key, so they are rewritten once here - // rather than special-cased forever at every read. - // - // Read-modify-write in JS rather than SQL JSON functions: SQLite's - // json_set and Postgres' jsonb operators disagree on almost everything, - // and 002 already established that a portable pass beats a clever one. - const rows = await db.selectFrom("licenses").select(["id", "entitlements"]).execute(); - for (const row of rows) { - let parsed: Record; - try { - parsed = JSON.parse(row.entitlements) as Record; - } catch { - continue; - } - if (!parsed || typeof parsed !== "object") continue; - if (!("allowance" in parsed) || "imports" in parsed) continue; - const { allowance, ...rest } = parsed; - await db - .updateTable("licenses") - .set({ entitlements: JSON.stringify({ ...rest, imports: allowance }) }) - .where("id", "=", row.id) - .execute(); - } - }, - }, - { - name: "006_support_requests", + name: "001_v1", up: async (db, engine) => { - if (!(await tableExists(db, engine, "support_requests"))) { - await db.schema - .createTable("support_requests") - .addColumn("id", "text", (column) => column.primaryKey()) - .addColumn("client_request_id", "text", (column) => column.notNull()) - .addColumn("license_id", "text", (column) => column.notNull()) - .addColumn("installation_id", "text", (column) => column.notNull()) - .addColumn("category", "text", (column) => column.notNull()) - .addColumn("urgency", "text", (column) => column.notNull()) - .addColumn("subject", "text", (column) => column.notNull()) - .addColumn("message", "text", (column) => column.notNull()) - .addColumn("reporter_name", "text") - .addColumn("app_version", "text") - .addColumn("platform", "text") - .addColumn("status", "text", (column) => column.notNull().defaultTo("queued")) - .addColumn("client_created_at", "text", (column) => column.notNull()) - .addColumn("received_at", "text", (column) => column.notNull()) - .addColumn("updated_at", "text", (column) => column.notNull()) - .addUniqueConstraint("support_requests_client_key", [ - "license_id", - "installation_id", - "client_request_id", - ]) - .addCheckConstraint( - "support_requests_category_check", - sql`category IN ('technical', 'licensing', 'billing', 'feature_request', 'other')`, - ) - .addCheckConstraint( - "support_requests_urgency_check", - sql`urgency IN ('low', 'normal', 'high', 'critical')`, - ) - .addCheckConstraint("support_requests_status_check", sql`status IN ('queued')`) - .execute(); - } - if (!(await tableExists(db, engine, "support_rate_limit_buckets"))) { - await db.schema - .createTable("support_rate_limit_buckets") - .addColumn("license_id", "text", (column) => column.notNull()) - .addColumn("installation_id", "text", (column) => column.notNull()) - .addColumn("window_start", "text", (column) => column.notNull()) - .addColumn("request_count", "integer", (column) => column.notNull()) - .addPrimaryKeyConstraint("support_rate_limit_buckets_pkey", [ - "license_id", - "installation_id", - "window_start", - ]) - .execute(); - } + await db.schema + .createTable("platform_records") + .addColumn("scope", "text", (c) => c.notNull()) + .addColumn("kind", "text", (c) => c.notNull()) + .addColumn("id", "text", (c) => c.notNull()) + .addColumn("data", "text", (c) => c.notNull()) + .addColumn("revision", "integer", (c) => c.notNull()) + .addPrimaryKeyConstraint("platform_records_pk", ["scope", "kind", "id"]) + .execute(); + await db.schema + .createTable("platform_lock") + .addColumn("id", "integer", (c) => c.primaryKey()) + .addColumn("value", "integer", (c) => c.notNull()) + .execute(); + await db.insertInto("platform_lock").values({ id: 1, value: 0 }).execute(); + await sql`CREATE TABLE audit_log (id ${sql.raw(engine === "postgres" ? "bigserial PRIMARY KEY" : "integer PRIMARY KEY AUTOINCREMENT")}, at TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, license_id TEXT, detail TEXT NOT NULL)`.execute( + db, + ); }, }, ]; @@ -331,9 +60,9 @@ const MIGRATION_LOCK_KEY = 4_919_071_113; * backends. The instance that waits then finds nothing left to do. */ export async function migrateToLatest(db: Kysely, engine: Engine): Promise { - if (engine !== "postgres") return applyMigrations(db, engine); return db.transaction().execute(async (trx) => { - await sql`SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_KEY})`.execute(trx); + if (engine === "postgres") + await sql`SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_KEY})`.execute(trx); return applyMigrations(trx, engine); }); } @@ -342,6 +71,10 @@ async function applyMigrations(db: Kysely, engine: Engine): Promise`SELECT name FROM schema_migrations`.execute(db); const done = new Set(applied.rows.map((row) => row.name)); + if ([...done].some((name) => name !== "001_v1")) + throw new Error( + "This build requires a fresh v1 database. Create/reset the development database; no older schema is imported.", + ); const ran: string[] = []; for (const migration of migrations) { if (done.has(migration.name)) continue; diff --git a/src/notifications.ts b/src/notifications.ts index 0b09f4c..4a98a5f 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -104,11 +104,6 @@ export function registerNotifications( .filter((v) => v.owner === owner) .map((v) => [v.eventId, v]), ); - const reads = new Set( - (await records.list(s, "notification_read")) - .filter((v) => v.owner === owner) - .map((v) => v.eventId), - ); return { items: existing .filter((n) => n.owner === owner && !states.get(n.eventId)?.deleted) @@ -120,7 +115,7 @@ export function registerNotifications( href: n.href, date: n.date, createdAt: n.createdAt, - read: states.get(n.eventId)?.read ?? reads.has(n.eventId), + read: states.get(n.eventId)?.read ?? false, })) .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)), preferences: { ...defaults, ...preference }, diff --git a/src/platform.ts b/src/platform.ts index 1447678..e003928 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -38,6 +38,7 @@ import { pickMatch, SIGNAL_KINDS, signalAvailability, + trialVerdict, } from "./machines.js"; import { registerNotifications } from "./notifications.js"; import { @@ -70,7 +71,7 @@ const trialSchema = z const definitionSchema = z .object({ id: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/), - type: z.enum(["number", "string", "boolean", "mixed"]), + type: z.enum(["number", "string", "boolean"]), description: z.string().max(500), defaultValue: valueSchema.optional(), minimum: z.number().finite().optional(), @@ -88,8 +89,7 @@ const definitionSchema = z context.addIssue({ code: "custom", message: "Minimum exceeds maximum." }); if ( definition.defaultValue !== undefined && - (definition.type === "mixed" || - typeof definition.defaultValue !== definition.type || + (typeof definition.defaultValue !== definition.type || (typeof definition.defaultValue === "number" && ((definition.minimum !== undefined && definition.defaultValue < definition.minimum) || (definition.maximum !== undefined && definition.defaultValue > definition.maximum)))) @@ -139,7 +139,7 @@ export interface Grant extends LicenseRow { interface Installation extends RecordValue { importAccountId?: string | null; trialConvertedAt?: string | null; - credentialHash: string | null; + credentialHash: string; registeredAt: string; lastSeenAt: string; appVersion: string; @@ -147,7 +147,7 @@ interface Installation extends RecordValue { userName: string; userEmail: string; licenseId: string | null; - machineSignals?: ReturnType; + machineSignals: ReturnType; signalsUpdatedAt?: string; } interface Binding extends RecordValue { @@ -173,9 +173,7 @@ function deviceSummary( signals?: ReturnType, ) { const groups = machines.filter((m) => m.installationIds.includes(installationId)); - const signalKinds = [ - ...new Set((signals ?? groups.flatMap((m) => m.signals)).map((s) => s.kind)), - ]; + const signalKinds = [...new Set((signals ?? []).map((s) => s.kind))]; return { machineGroups: groups.map((m) => ({ id: m.id, supportReference: m.supportReference })), signalKinds, @@ -291,38 +289,7 @@ export class Platform { ...this.options.config.signingKeys.retired, ], } as Product); - const licenses = await records.db.selectFrom("licenses").selectAll().execute(); const definitions = new Map>(); - for (const row of licenses) { - const { - key_hash, - issued_to, - activation_limit, - issued_at, - expires_at, - revoked_at, - ...rest - } = row; - const entitlements = JSON.parse(row.entitlements) as Entitlements; - for (const [key, value] of Object.entries(entitlements)) { - const types = definitions.get(key) ?? new Set(); - types.add(typeof value); - definitions.set(key, types); - } - await records.put(scope("default"), "license", { - ...rest, - entitlements, - productId: "default", - keyHash: key_hash, - issuedTo: issued_to, - activationLimit: activation_limit, - issuedAt: issued_at, - expiresAt: expires_at, - revokedAt: revoked_at, - keyReference: "legacy-" + row.id.slice(0, 8), - subjectKind: "installation", - } as Grant); - } for (const [id, preset] of Object.entries(this.options.config.licenses.planPresets)) { await records.put(scope("default"), "plan", { id, @@ -339,77 +306,15 @@ export class Platform { definitions.set(key, types); } } - for (const [id, types] of definitions) + for (const [id, types] of definitions) { + if (types.size !== 1) + throw new Error("Configured plans disagree on the entitlement type for " + id + "."); await records.put(scope("default"), "definition", { id, - type: types.size === 1 ? [...types][0] : "mixed", + type: [...types][0], description: "", archived: false, } as Definition); - for (const row of await records.db.selectFrom("installations").selectAll().execute()) { - await records.put(scope("default"), "installation", { - id: row.installation_id, - credentialHash: null, - registeredAt: row.registered_at, - lastSeenAt: row.last_seen_at, - appVersion: row.app_version, - platform: row.platform, - userName: row.user_name, - userEmail: row.user_email, - licenseId: row.license_id, - } as Installation); - } - for (const row of await records.db.selectFrom("activations").selectAll().execute()) - await records.put(scope("default"), "binding", { - id: row.license_id + ":" + row.installation_id, - licenseId: row.license_id, - installationId: row.installation_id, - activatedAt: row.activated_at, - deactivatedAt: row.deactivated_at, - } as Binding); - for (const row of await records.db.selectFrom("support_requests").selectAll().execute()) - await records.put(scope("default"), "ticket", { - id: row.id, - licenseId: row.license_id, - installationId: row.installation_id, - category: row.category, - urgency: row.urgency, - subject: row.subject, - message: row.message, - reporterName: row.reporter_name, - appVersion: row.app_version, - platform: row.platform, - clientRequestId: row.client_request_id, - clientCreatedAt: row.client_created_at, - receivedAt: row.received_at, - updatedAt: row.updated_at, - status: "queued", - assignedTo: null, - messages: [], - } as Ticket); - const machines = await records.db.selectFrom("machine_groups").selectAll().execute(); - for (const machine of machines) { - const signals = await records.db - .selectFrom("machine_signals") - .select(["kind", "hash"]) - .where("group_id", "=", machine.id) - .execute(); - const installations = await records.db - .selectFrom("machine_installations") - .select("installation_id") - .where("group_id", "=", machine.id) - .execute(); - await records.put(scope("default"), "machine", { - id: machine.id, - supportReference: machine.support_reference, - signals: normalizeSignals(signals), - installationIds: installations.map((i) => i.installation_id), - registrationCount: machine.registration_count, - clearedAtCount: machine.cleared_at_count, - clearedAt: machine.cleared_at, - firstRegisteredAt: machine.first_registered_at, - lastRegisteredAt: machine.last_registered_at, - } as Machine); } }; if (existingRecords) await seed(existingRecords); @@ -450,7 +355,7 @@ export class Platform { async token(product: Product, license: Grant, installationId: string, records = this.records) { const account = await importAccount(records, product.id, license); return this.sign(product, { - v: account ? 5 : 4, + v: 1, kind: "license", iss: "default", productId: product.id, @@ -467,15 +372,11 @@ export class Platform { this.options.now().getTime() + product.graceDays * 86400000, ).toISOString(), status: this.status(license), - ...(account - ? { - accountId: account.id, - authorizationRevision: account.authorizationRevision, - paidImportsGranted: account.granted, - recoveryGeneration: account.recoveryGeneration, - trialConverted: true, - } - : {}), + accountId: account?.id ?? null, + authorizationRevision: account?.authorizationRevision ?? license.revision ?? 1, + paidImportsGranted: account?.granted ?? null, + recoveryGeneration: account?.recoveryGeneration ?? 0, + trialConverted: true, }); } async authenticate(pid: string, iid: string, secret: string, records = this.records) { @@ -495,7 +396,6 @@ export class Platform { if ( !def || def.archived || - def.type === "mixed" || typeof value !== def.type || (typeof value === "number" && ((def.minimum !== undefined && value < def.minimum) || @@ -856,12 +756,9 @@ export async function registerPlatform(app: FastifyInstance, options: PlatformOp const used = licenses.some((l) => def.id in l.entitlements) || plans.some((p) => def.id in p.entitlements); - if (prior && prior.type !== def.type && used && prior.type !== "mixed") + if (prior && prior.type !== def.type && used) throw new DomainError("definition_in_use", "Used entitlement type cannot change."); - if ( - def.type === "mixed" || - (def.defaultValue !== undefined && typeof def.defaultValue !== def.type) - ) + if (def.defaultValue !== undefined && typeof def.defaultValue !== def.type) throw new DomainError( "bad_definition", "Resolve ambiguous entitlement types before import.", @@ -970,7 +867,7 @@ export async function registerPlatform(app: FastifyInstance, options: PlatformOp (await records.list(scope(req.params.productId), "plan")).some( (p) => input.id in p.entitlements, ); - if (prior && used && prior.type !== def.type && prior.type !== "mixed") + if (prior && used && prior.type !== def.type) throw new DomainError( "definition_in_use", "A used entitlement cannot change type.", @@ -1385,46 +1282,22 @@ export async function registerPlatform(app: FastifyInstance, options: PlatformOp req.params.id, ); if (!license) throw new DomainError("unknown_license", "No such license.", 404); - const supplied = z.string().optional().parse(req.body?.key); - if (supplied !== undefined) { - if (hashProductKey(supplied) !== license.keyHash) - throw new DomainError("key_mismatch", "This key does not match the original."); - await atomic(options.database, async (records) => { - const current = (await records.get( - scope(req.params.productId), - "license", - req.params.id, - ))!; - await records.put(scope(req.params.productId), "license", { - ...current, - encryptedKey: platform.vault.encrypt( - supplied.trim().toUpperCase(), - "license:" + req.params.productId + ":" + license.id, - ), - } as Grant); - }); - } - const current = (await platform.records.get( - scope(req.params.productId), - "license", - license.id, - ))!; - if (!current.encryptedKey) + z.object({ password: z.string().optional() }) + .strict() + .parse(req.body ?? {}); + if (!license.encryptedKey) throw new DomainError( "original_key_unavailable", - "Original key unavailable. Securely re-enter it to enable storage.", + "Stored key unavailable. Restore a current backup or explicitly replace the key.", 404, ); - await platform.audit( - req.actor, - supplied ? "license.key.store" : "license.key.reveal", - req.params.productId, - { licenseId: license.id }, - ); + await platform.audit(req.actor, "license.key.reveal", req.params.productId, { + licenseId: license.id, + }); reply.header("Cache-Control", "no-store"); return { key: platform.vault.decrypt( - current.encryptedKey, + license.encryptedKey, "license:" + req.params.productId + ":" + license.id, ), }; @@ -1656,11 +1529,7 @@ export async function registerPlatform(app: FastifyInstance, options: PlatformOp let machine = knownMachine ?? (match ? machines.find((m) => m.id === match.groupId)! : null); const converted = Boolean(prior?.trialConvertedAt); - const verdict = converted - ? "spent" - : !machine || machine.clearedAtCount === machine.registrationCount - ? "fresh" - : "spent"; + const verdict = converted ? "spent" : trialVerdict(machine); machine ??= { id: randomUUID(), signals: [], @@ -1685,7 +1554,7 @@ export async function registerPlatform(app: FastifyInstance, options: PlatformOp trial: product.trial.enabled ? { token: platform.sign(product, { - v: product.importPolicy ? 3 : 2, + v: 1, kind: "trial", iss: "default", productId: pid, @@ -1700,7 +1569,7 @@ export async function registerPlatform(app: FastifyInstance, options: PlatformOp ).toISOString() : null, supportReference: verdict === "spent" ? machine.supportReference : null, - ...(product.importPolicy ? { converted } : {}), + converted, }), } : null, @@ -1876,7 +1745,6 @@ export async function registerPlatform(app: FastifyInstance, options: PlatformOp account.ownerInstallationId = input.installationId; if (!existing || existing.deactivatedAt) account.authorizationRevision++; await records.put(scope(pid), "import_account", account); - installation.trialConvertedAt ??= platform.time(); installation.importAccountId = account.id; } await records.put(scope(pid), "binding", { @@ -1888,6 +1756,7 @@ export async function registerPlatform(app: FastifyInstance, options: PlatformOp } as Binding); installation.licenseId = license.id; } + installation.trialConvertedAt ??= platform.time(); installation.lastSeenAt = platform.time(); await records.put(scope(pid), "installation", installation); return { diff --git a/src/replica.ts b/src/replica.ts index ea3856c..7f961cd 100644 --- a/src/replica.ts +++ b/src/replica.ts @@ -23,8 +23,8 @@ import { migrateToLatest } from "./migrations.js"; * own. Two writable ledgers with automatic failover would eventually issue one * product key twice and count a seat twice, which is worse than a gap. * - * Whole tables, every time. `licenses` has no `updated_at`, so an edit through - * `PATCH /admin/licenses/:id` changes nothing a timestamp-based sync could + * Whole tables, every time. Scoped records are the source of truth; an edit through + * `PATCH /admin/products/:productId/licenses/:id` changes nothing a timestamp-based sync could * notice: an incremental copy would silently miss it. At a license server's row * counts a full snapshot is both correct and quick, so correct wins. * @@ -44,24 +44,7 @@ interface LedgerTable { numeric: string[]; } -/** - * The tables, in **insert order**: `installations.license_id` and - * `activations.license_id` both reference `licenses`, so licenses go first and - * deletes run in reverse. (`audit_log.license_id` is a plain column with no - * foreign key — the audit trail outlives the licenses it describes.) The three - * machine tables follow the same rule among themselves: signals and - * installations both reference a group. - * - * **Every ledger table has to be listed here.** A table left out is not copied, - * not compared, and not reported — `verify` calls a mirror missing it a match. - * The trial ledger is the one where that is worst: silently dropping it during - * a move to Postgres hands every customer in the file a fresh trial, and - * nothing about the migration looks wrong. - * - * Columns are listed rather than `SELECT *`: a database that predates - * migration 003 still carries a legacy `allowance` column, and column order is - * not guaranteed to match between two engines. - */ +/** Current application tables, copied as a consistent complete snapshot. */ export const LEDGER_TABLES: LedgerTable[] = [ { name: "platform_records", @@ -70,108 +53,12 @@ export const LEDGER_TABLES: LedgerTable[] = [ numeric: ["revision"], }, { name: "platform_lock", columns: ["id", "value"], key: ["id"], numeric: ["value"] }, - { - name: "licenses", - columns: [ - "id", - "key_hash", - "plan", - "entitlements", - "issued_to", - "status", - "activation_limit", - "issued_at", - "expires_at", - "revoked_at", - ], - key: ["id"], - numeric: ["activation_limit"], - }, - { - name: "installations", - columns: [ - "installation_id", - "registered_at", - "last_seen_at", - "app_version", - "platform", - "user_name", - "user_email", - "license_id", - ], - key: ["installation_id"], - numeric: [], - }, - { - name: "activations", - columns: ["license_id", "installation_id", "activated_at", "deactivated_at"], - key: ["license_id", "installation_id"], - numeric: [], - }, - { - name: "machine_groups", - columns: [ - "id", - "support_reference", - "first_registered_at", - "last_registered_at", - "registration_count", - "recheck_count", - "trial_spent_reported", - "cleared_at", - "cleared_at_count", - ], - key: ["id"], - // cleared_at_count is nullable, and normalizeValue keeps null as null — - // listing it here only decides how a present value is compared. - numeric: ["registration_count", "recheck_count", "trial_spent_reported", "cleared_at_count"], - }, - { - name: "machine_signals", - columns: ["group_id", "kind", "hash", "first_seen_at", "last_seen_at"], - key: ["group_id", "kind", "hash"], - numeric: [], - }, - { - name: "machine_installations", - columns: ["group_id", "installation_id", "first_seen_at"], - key: ["group_id", "installation_id"], - numeric: [], - }, { name: "audit_log", columns: ["id", "at", "actor", "action", "license_id", "detail"], key: ["id"], numeric: ["id"], }, - { - name: "support_requests", - columns: [ - "id", - "client_request_id", - "license_id", - "installation_id", - "category", - "urgency", - "subject", - "message", - "reporter_name", - "app_version", - "platform", - "status", - "client_created_at", - "received_at", - "updated_at", - ], - key: ["id"], - numeric: [], - }, - { - name: "support_rate_limit_buckets", - columns: ["license_id", "installation_id", "window_start", "request_count"], - key: ["license_id", "installation_id", "window_start"], - numeric: ["request_count"], - }, ]; /** Rows per INSERT. Both engines cap the bind parameters in one statement. */ @@ -259,7 +146,7 @@ async function assertLedgerSchema(handle: DatabaseHandle, role: string): Promise if (missing.length > 0) { throw new Error( `The ${role} database is on an older schema: ${table.name} is missing ` + - `${missing.join(", ")}. Start the service against it once so migrations run, then retry.`, + `${missing.join(", ")}. Use a current v1 source database.`, ); } } diff --git a/src/schema.ts b/src/schema.ts index 2203256..6c30a0f 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,140 +1,15 @@ import type { Generated } from "kysely"; -/** - * The ledger's tables, typed for Kysely. - * - * Portability choices that keep one query set working on both SQLite and - * Postgres: - * - timestamps are ISO-8601 **text**, not native date types, so comparisons - * and ordering behave identically on both (ISO strings sort correctly); - * - `entitlements` is JSON serialized to **text** rather than jsonb — the - * server never queries inside it, so a portable column beats a clever one; - * - booleans are `integer` 0/1, because SQLite has no boolean type and a - * column that reads back as `1` on one engine and `true` on the other is a - * bug waiting for whichever engine the author did not develop against. - */ - -export interface LicensesTable { - id: string; - key_hash: string; - plan: string; - entitlements: string; - issued_to: string; - status: string; - activation_limit: number; - issued_at: string; - expires_at: string | null; - revoked_at: string | null; -} - -export interface ActivationsTable { - license_id: string; - installation_id: string; - activated_at: string; - deactivated_at: string | null; -} - -export interface InstallationsTable { - installation_id: string; - registered_at: string; - last_seen_at: string; - app_version: string; - platform: string; - user_name: string; - user_email: string; - license_id: string | null; -} - -/** - * One physical machine, as far as the trial ledger can tell. - * - * Created by the first registration that cannot be matched to an existing - * group, and thereafter the thing a `fresh`/`spent` verdict is about. Carries - * no hardware identifiers itself — those live in `machine_signals`, one row per - * hash, so a noisy identifier can be counted across groups without a scan. - */ -export interface MachineGroupsTable { - id: string; - /** Short and quotable; what a customer reads out to support. Unique. */ - support_reference: string; - first_registered_at: string; - last_registered_at: string; - registration_count: number; - /** Registrations the operator asked for, so repeats are distinguishable. */ - recheck_count: number; - /** The client's own claim that its trial is gone. Evidence, never a verdict. */ - trial_spent_reported: number; - /** Set by support; kept for the record even once the grant is used up. */ - cleared_at: string | null; - /** registration_count at the moment of the clear. Equal = the grant is unused. */ - cleared_at_count: number | null; -} - -export interface MachineSignalsTable { - group_id: string; - kind: string; - /** SHA-256 of the identifier, lowercase hex. Never the identifier itself. */ - hash: string; - first_seen_at: string; - last_seen_at: string; -} - -export interface MachineInstallationsTable { - group_id: string; - installation_id: string; - first_seen_at: string; -} - -export interface AuditLogTable { - id: Generated; - at: string; - actor: string; - action: string; - license_id: string | null; - detail: string; -} - -export interface SupportRequestsTable { - id: string; - client_request_id: string; - license_id: string; - installation_id: string; - category: string; - urgency: string; - subject: string; - message: string; - reporter_name: string | null; - app_version: string | null; - platform: string | null; - status: string; - client_created_at: string; - received_at: string; - updated_at: string; -} - -export interface SupportRateLimitBucketsTable { - license_id: string; - installation_id: string; - window_start: string; - request_count: number; -} - +/** Current product-scoped v1 application schema, shared by SQLite and PostgreSQL. */ export interface Database { - platform_records: { - scope: string; - kind: string; - id: string; - data: string; - revision: number; - }; + platform_records: { scope: string; kind: string; id: string; data: string; revision: number }; platform_lock: { id: number; value: number }; - licenses: LicensesTable; - activations: ActivationsTable; - installations: InstallationsTable; - machine_groups: MachineGroupsTable; - machine_signals: MachineSignalsTable; - machine_installations: MachineInstallationsTable; - audit_log: AuditLogTable; - support_requests: SupportRequestsTable; - support_rate_limit_buckets: SupportRateLimitBucketsTable; + audit_log: { + id: Generated; + at: string; + actor: string; + action: string; + license_id: string | null; + detail: string; + }; } diff --git a/src/sign.ts b/src/sign.ts index be72dca..c2382e1 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -1,124 +1,6 @@ -import { generateKeyPairSync, sign } from "node:crypto"; +import { generateKeyPairSync } from "node:crypto"; -/** - * The signed tokens — the artifacts that cross from the vendor's service into - * an installed copy of the app. - * - * Everything the app needs to enforce entitlement offline is in the payload, - * and the Ed25519 signature is what makes storage trustworthy: the app verifies - * with an embedded public key, and only this service holds the private key. A - * fake license server can answer requests all day; it cannot mint a token the - * app will accept, which is why the app's server URL does not need protecting. - * - * Consuming apps should copy these shapes rather than import them — the server - * deploys on its own, and a shared package would couple release cycles for one - * small interface. See docs/verify-token.md for the verification snippet. - * - * ## Three payload versions, negotiated per request - * - * A client declares the highest version it can parse (`maxTokenVersion`) and the - * server mints at `min(declared, TOKEN_VERSION_MAX)` — never above, so a token - * can only ever be too old for a verifier, never too new. Omitting the field - * means v1, which is what every client built before negotiation existed sends. - * - * - **v1** is the original shape: a scalar `allowance`, no `kid`, no - * entitlements map. Deliberately left bare — a v1-only verifier rejects - * fields it does not know, so adding to it would break the clients it exists - * for. - * - **v2** adds the open `entitlements` map and `kid`, and keeps `allowance` as - * a mirror of `entitlements.imports` so a v1-shaped reader still works. - * - **v3** drops the scalar. `entitlements` is the only source of truth. - */ - -/** Vendor-defined entitlements (counts, flags, tiers), signed verbatim. */ -export type TokenEntitlements = Record; - -interface LicenseTokenCommon { - licenseId: string; - plan: string; - issuedTo: string; - /** The one installation this token is valid for. */ - installationId: string; - issuedAt: string; - expiresAt: string | null; - /** How long the app may keep working without reaching this service again. */ - offlineGraceUntil: string; - /** - * Signed even when the news is bad: a revoked or expired status arrives as a - * token too, so the app persists an *authenticated* refusal rather than a - * bare flag anyone could delete or a response anyone could fake. - */ - status: "active" | "revoked" | "expired"; -} - -/** The original shape. No `kid`, no entitlements map — see the note above. */ -export interface LicenseTokenV1 extends LicenseTokenCommon { - v: 1; - /** Absent when the license carries no numeric `imports` entitlement. */ - allowance?: number; -} - -/** Entitlements map plus `kid`, with the v1 scalar kept as a mirror. */ -export interface LicenseTokenV2 extends LicenseTokenCommon { - v: 2; - kid: string; - entitlements: TokenEntitlements; - /** v1 compatibility mirror of entitlements.imports. */ - allowance?: number; -} - -/** Entitlements only. The scalar is gone for good. */ -export interface LicenseTokenV3 extends LicenseTokenCommon { - v: 3; - kid: string; - entitlements: TokenEntitlements; -} - -export type LicenseTokenPayload = LicenseTokenV1 | LicenseTokenV2 | LicenseTokenV3; - -/** - * The trial verdict, signed. - * - * A first-run installation asks whether this machine has already spent its - * trial. The answer has to be tamper-evident for the same reason a revoked - * license does: an unsigned `{"verdict":"fresh"}` is one proxy away from being - * free software. Versioned separately from license tokens — it is a different - * artifact with a different lifetime — and starts at 1 with `kid` present, - * because no verifier predates it. - */ -export interface TrialTokenPayload { - v: 1; - kind: "trial"; - kid: string; - installationId: string; - verdict: "fresh" | "spent"; - /** The trial's size, and 0 when the verdict is "spent". */ - allowance: number; - issuedAt: string; - /** What the customer reads out to support. Only meaningful when spent. */ - supportReference: string | null; -} - -function base64url(input: Buffer): string { - return input.toString("base64url"); -} - -/** Payload JSON, signed, both halves base64url — the one token format here. */ -function signPayload(payload: unknown, privateKeyPem: string): string { - const body = Buffer.from(JSON.stringify(payload), "utf8"); - const signature = sign(null, body, privateKeyPem); - return `${base64url(body)}.${base64url(signature)}`; -} - -export function signLicenseToken(payload: LicenseTokenPayload, privateKeyPem: string): string { - return signPayload(payload, privateKeyPem); -} - -export function signTrialToken(payload: TrialTokenPayload, privateKeyPem: string): string { - return signPayload(payload, privateKeyPem); -} - -/** One vendor keypair, generated once. The private half never ships with the app. */ +/** Generate a signing pair deliberately; protocol changes never rotate existing keys. */ export function generateSigningKeyPair(): { publicKey: string; privateKey: string } { const { publicKey, privateKey } = generateKeyPairSync("ed25519"); return { diff --git a/src/token-contract.ts b/src/token-contract.ts index b679a72..0e40fe5 100644 --- a/src/token-contract.ts +++ b/src/token-contract.ts @@ -16,7 +16,7 @@ export const licenseTokenSchema = z .object({ ...common, kind: z.literal("license"), - v: z.literal(4), + v: z.literal(1), licenseId: id, plan: id, entitlements: entitlementsSchema, @@ -24,35 +24,30 @@ export const licenseTokenSchema = z subject: z.object({ kind: z.enum(["installation", "service"]), id }).strict(), offlineGraceUntil: date, status: z.enum(["active", "revoked", "expired"]), + accountId: id.nullable(), + authorizationRevision: z.number().int().positive().safe(), + paidImportsGranted: z.number().int().nonnegative().safe().nullable(), + recoveryGeneration: z.number().int().nonnegative().safe(), + trialConverted: z.literal(true), }) .strict() - .refine((p) => p.subject.id === p.installationId); + .refine((p) => p.subject.id === p.installationId) + .refine((p) => + p.accountId === null + ? p.paidImportsGranted === null && p.recoveryGeneration === 0 + : p.paidImportsGranted !== null && p.subject.kind === "installation", + ); export const trialTokenSchema = z .object({ ...common, kind: z.literal("trial"), - v: z.literal(2), + v: z.literal(1), verdict: z.enum(["fresh", "spent"]), allowance: z.number().int().nonnegative().nullable(), supportReference: z.string().nullable(), + converted: z.boolean(), }) .strict(); -export const importLicenseTokenSchema = z - .object({ - ...licenseTokenSchema.shape, - v: z.literal(5), - accountId: id, - authorizationRevision: z.number().int().positive().safe(), - paidImportsGranted: z.number().int().nonnegative().safe(), - recoveryGeneration: z.number().int().nonnegative().safe(), - trialConverted: z.literal(true), - }) - .strict() - .refine((p) => p.subject.id === p.installationId); -export const importTrialTokenSchema = trialTokenSchema.extend({ - v: z.literal(3), - converted: z.boolean(), -}); export const recoveryTokenSchema = z .object({ ...common, @@ -73,9 +68,4 @@ export const recoveryTokenSchema = z expiresAt: date, }) .strict(); -export const productTokenSchema = z.union([ - licenseTokenSchema, - trialTokenSchema, - importLicenseTokenSchema, - importTrialTokenSchema, -]); +export const productTokenSchema = z.union([licenseTokenSchema, trialTokenSchema]); diff --git a/test/app.test.ts b/test/app.test.ts index 5a5fd51..dfbf188 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -114,7 +114,7 @@ test("fresh contract removes legacy routes and preserves signed revocation", asy const verified = verifyLicenseToken(r.json().token, pair.publicKey); assert.equal(verified.valid, true); const payload = verified.payload as any; - assert.equal(payload.v, 4); + assert.equal(payload.v, 1); assert.equal(payload.productId, "default"); assert.equal(payload.entitlements.imports, 100); assert.equal("allowance" in payload, false); @@ -247,7 +247,7 @@ test("product isolation and staged rotation", async (t) => { assert.equal(verifyLicenseToken(after, key.publicKeyPem).valid, true); assert.equal(verifyLicenseToken(after, pair.publicKey).valid, false); }); -test("encrypted key storage never leaks through listings, and legacy reentry verifies hash", async (t) => { +test("encrypted key storage never leaks through listings and authenticated reveal is audited", async (t) => { const h = await harness(t), l = await h.issue(); const list = (await h.admin("GET", "/licenses")).json().licenses[0]; @@ -502,41 +502,6 @@ test("manual snapshots do not require schedule or archive keys; archives verify const bytes = await service.bytes(completed); assert.equal(bytes.toString().includes("platform_records"), false); }); -test("existing ledger imports without erasing licenses and refuses legacy installation takeover", async (t) => { - const db = await LicenseDatabase.open({ sqlitePath: ":memory:" }); - const l = await db.createLicense({ - plan: "standard", - entitlements: { imports: 10 }, - issuedTo: "Legacy", - activationLimit: 1, - }); - await db.registerInstallation({ installationId: "legacy" }); - await db.bindActivation(l.license.id, "legacy"); - const app = await buildLicenseApp({ - database: db, - privateKey: pair.privateKey, - adminToken: "test-admin", - keyring: ring, - }); - t.after(async () => { - await app.close(); - await db.close(); - }); - const list = await app.inject({ - method: "GET", - url: prefix + "/licenses", - headers: { authorization: "Bearer test-admin" }, - }); - assert.equal(list.json().licenses[0].id, l.license.id); - assert.equal(list.json().licenses[0].keyStored, false); - const r = await app.inject({ - method: "POST", - url: "/v1/products/default/register", - headers: { authorization: "Bearer " + randomBytes(32).toString("base64url") }, - payload: { installationId: "legacy", requestId: randomUUID() }, - }); - assert.equal(r.statusCode, 401); -}); test("Postgres product workflow serializes seats and metered quotas", async (t) => { const base = process.env.TEST_DATABASE_URL; diff --git a/test/auth-upgrade.test.ts b/test/auth-upgrade.test.ts deleted file mode 100644 index 16fbed0..0000000 --- a/test/auth-upgrade.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; -import test from "node:test"; - -import { createAuth, closeAuth, ensureAuthSchema } from "../src/auth.js"; -import { createPostgresPool } from "../src/database.js"; - -test("Postgres operators upgrade from the required legacy issuer column without losing sign-in data", async (t) => { - const base = process.env.TEST_DATABASE_URL; - if (!base) return t.skip("set TEST_DATABASE_URL to exercise the Better Auth issuer upgrade"); - const schema = "kf_auth_upgrade_" + randomUUID().replaceAll("-", ""); - const owner = createPostgresPool(base); - await owner.query(`CREATE SCHEMA "${schema}"`); - const url = new URL(base); - url.searchParams.set("options", "-c search_path=" + schema); - const pool = createPostgresPool(url.href); - const auth = createAuth({ - dbPath: ":memory:", - url: url.href, - secret: "upgrade-test-secret-0123456789abcdef", - baseURL: "http://localhost", - allowSignUp: true, - }); - t.after(async () => { - await closeAuth(auth); - await pool.end(); - await owner.query(`DROP SCHEMA "${schema}" CASCADE`); - await owner.end(); - }); - await ensureAuthSchema(auth); - const first = await auth.api.signUpEmail({ - body: { - email: "old@example.test", - password: "Legacy-password-2026!", - name: "Existing operator", - }, - }); - const before = (await pool.query('SELECT "password", "userId" FROM "account"')).rows[0]; - await pool.query( - 'ALTER TABLE "account" ADD COLUMN "issuer" TEXT NOT NULL DEFAULT \'legacy-issuer\'', - ); - await pool.query('ALTER TABLE "account" ALTER COLUMN "issuer" DROP DEFAULT'); - await pool.query( - 'CREATE UNIQUE INDEX "account_issuer_accountId_uidx" ON "account" ("issuer", "accountId")', - ); - await ensureAuthSchema(auth); - await ensureAuthSchema(auth); - assert.deepEqual( - ( - await pool.query('SELECT "password", "userId" FROM "account" WHERE "userId" = $1', [ - first.user.id, - ]) - ).rows[0], - before, - ); - assert.equal( - (await pool.query('SELECT "issuer" FROM "account" WHERE "userId" = $1', [first.user.id])) - .rows[0].issuer, - "legacy-issuer", - ); - const signedIn = await auth.api.signInEmail({ - body: { email: "old@example.test", password: "Legacy-password-2026!" }, - }); - assert.equal(signedIn.user.id, first.user.id); - const second = await auth.api.signUpEmail({ - body: { email: "new@example.test", password: "New-password-2026!", name: "New operator" }, - }); - assert.equal( - (await pool.query('SELECT "issuer" FROM "account" WHERE "userId" = $1', [second.user.id])) - .rows[0].issuer, - null, - ); - assert.equal( - ( - await pool.query( - "SELECT count(*) AS count FROM pg_indexes WHERE schemaname = $1 AND indexname = 'account_issuer_accountId_uidx'", - [schema], - ) - ).rows[0].count, - "0", - ); -}); diff --git a/test/backup-storage.test.ts b/test/backup-storage.test.ts index 6f887e1..c121292 100644 --- a/test/backup-storage.test.ts +++ b/test/backup-storage.test.ts @@ -14,7 +14,7 @@ import { LicenseDatabase } from "../src/db.js"; import { Platform, WORKSPACE } from "../src/platform.js"; import { generateSigningKeyPair } from "../src/sign.js"; -test("backup credentials migrate once, remain encrypted, preserve archive versions and verify without environment credentials", async (t) => { +test("backup credentials are configured explicitly, remain encrypted, preserve archive versions and verify without environment credentials", async (t) => { const database = await LicenseDatabase.open({ sqlitePath: ":memory:" }); t.after(() => database.close()); const platform = new Platform({ @@ -43,10 +43,9 @@ test("backup credentials migrate once, remain encrypted, preserve archive versio }, }; const storage = new BackupStorage(platform, options); - await storage.migrate(); + await storage.save("blob", { revision: 0, token: "old-private-provider-token" }); const version = await storage.version("blob"); assert.ok(version); - await storage.migrate(); assert.equal(await storage.version("blob"), version); const before = await storage.metadata("blob"); assert.equal(before.source, "database"); diff --git a/test/backups.test.ts b/test/backups.test.ts index 51957aa..a25c8a5 100644 --- a/test/backups.test.ts +++ b/test/backups.test.ts @@ -193,7 +193,7 @@ test("S3 and R2 use separate credentials and buckets and support remote read/del assert.ok(auth.some((a) => a.includes("Credential=R2_TEST/"))); assert.equal(files.size, 0); }); -test("daily UTC schedules, toggles, migration and retention are independent per destination", async (t) => { +test("daily UTC schedules, toggles and retention are independent per destination", async (t) => { const removed: string[] = [], stored = new Map(); const mock = (id: string): BackupDriver => ({ @@ -210,25 +210,12 @@ test("daily UTC schedules, toggles, migration and retention are independent per const h = await fixture(t, { drivers: { s3: mock("s3"), r2: mock("r2"), uploadthing: mock("uploadthing") }, }); - await atomic(h.platform.options.database, (r) => - r.put(WORKSPACE, "backup-schedule", { - id: "default", - destination: "s3", - enabled: true, - hourUTC: 13, - retentionDays: 1, - nextAt: "2026-10-12T13:00:00.000Z", - } as Schedule), - ); - await h.service.migrateSchedules(); - await h.service.migrateSchedules(); - const migrated = (await h.platform.records.get(WORKSPACE, "backup-schedule", "s3"))!; - assert.equal(migrated.enabled, true); - assert.equal(migrated.minuteUTC, 0); - assert.equal( - (await h.platform.records.get(WORKSPACE, "backup-schedule", "default"))!.enabled, - false, - ); + await h.service.saveSchedule("s3", { + enabled: true, + hourUTC: 13, + minuteUTC: 0, + retentionDays: 1, + }); await h.service.saveSchedule("r2", { enabled: true, hourUTC: 13, diff --git a/test/client-sdk.test.ts b/test/client-sdk.test.ts index d8e5fa1..2377d66 100644 --- a/test/client-sdk.test.ts +++ b/test/client-sdk.test.ts @@ -433,7 +433,7 @@ test("Linux/macOS registration and authenticated backfill preserve grants, seats }); const client = new KeyForgeClient(h.options(storage, { deviceSignalsProvider: provider })); await client.startTrial(); - await client.consumeTrial(3); + await client.consumeImport(randomUUID(), 3); const issued = await h.issue(); await client.activate(issued.key); const original = (await storage.load())!; @@ -446,7 +446,7 @@ test("Linux/macOS registration and authenticated backfill preserve grants, seats await client.register(); assert.equal((await client.refresh()).state, "licensed"); assert.deepEqual((await storage.load())!.registration, original.registration); - assert.equal((await storage.load())!.trialConsumed, 3); + assert.equal((await storage.load())!.importUsage.trialConsumed, 3); assert.deepEqual( (await h.admin("GET", "/machines")).machines.map((m: any) => [m.id, m.registrationCount]), groups, @@ -480,7 +480,7 @@ test("Device backfill is authenticated, product isolated and leaves trials, seat }); const client = new KeyForgeClient(options); await client.startTrial(); - await client.consumeTrial(2); + await client.consumeImport(randomUUID(), 2); const issued = await h.issue(); await client.activate(issued.key); const state = (await storage.load())!; @@ -500,7 +500,7 @@ test("Device backfill is authenticated, product isolated and leaves trials, seat ), bindings.activations.map((b: any) => [b.installationId, b.activatedAt, b.deactivatedAt]), ); - assert.equal((await storage.load())!.trialConsumed, 2); + assert.equal((await storage.load())!.importUsage.trialConsumed, 2); assert.deepEqual((await storage.load())!.registration, originalRegistration); const installation = (await h.admin("GET", "/installations")).installations[0]; assert.equal(installation.signalAvailability, "Partial"); @@ -533,7 +533,7 @@ test("Device backfill is authenticated, product isolated and leaves trials, seat assert.equal(recollected, false); }); -test("Legacy backfill anchors by installation, never merges matching groups and tolerates missing signals", async (t) => { +test("Signal updates anchor by installation, never merges matching groups and tolerates missing signals", async (t) => { const h = await fixture(t), storage = new MemoryStorage(); const oldSignals = [ @@ -657,6 +657,39 @@ test("SDK online activation, offline restart, grace expiry and signed negative r assert.equal((await client.getAuthorization()).state, "revoked"); }); +test("v1 generic license revisions reject replay and conflicting terms", async (t) => { + const h = await fixture(t), + storage = new MemoryStorage(); + const client = new KeyForgeClient(h.options(storage)); + const issued = await h.issue(); + await client.activate(issued.key); + const state = (await storage.load())!; + const grant = await verifySignedToken(state.licenseToken!, { + productId: "default", + installationId: state.installationId, + publicKeys: { "sdk-key": h.pair.publicKey }, + }); + const replay = (changes: object) => + new KeyForgeClient( + h.options(storage, { + fetch: async () => { + const body = Buffer.from(JSON.stringify({ ...grant, ...changes })); + const token = + body.toString("base64url") + + "." + + sign(null, body, h.pair.privateKey).toString("base64url"); + return new Response(JSON.stringify({ token })); + }, + }), + ); + await assert.rejects(() => replay({ issuedTo: "Changed without revision" }).refresh(), { + code: "revision_conflict", + }); + assert.equal((await client.getAuthorization()).state, "licensed"); + await replay({ authorizationRevision: 2, issuedTo: "Updated customer" }).refresh(); + await assert.rejects(() => replay({}).refresh(), { code: "stale_grant" }); +}); + test("SDK trial is explicit, allowance survives refresh/restart, disabled and spent trials fail closed", async (t) => { const h = await fixture(t); await h.product({ trial: { enabled: true, allowance: 3, durationDays: 2 } }); @@ -669,24 +702,23 @@ test("SDK trial is explicit, allowance survives refresh/restart, disabled and sp await client.register(); assert.equal((await client.getAuthorization()).state, "unlicensed"); assert.equal((await client.startTrial()).state, "trial"); - const consumed = await client.consumeTrial(2); - assert.equal(consumed.state === "trial" && consumed.remaining, 1); + const consumed = await client.consumeImport(randomUUID(), 2); + assert.equal( + consumed.authorization.state === "trial" && + (consumed.authorization.state === "trial" ? consumed.authorization.remaining : null), + 1, + ); const restarted = new KeyForgeClient(h.options(storage)); const grant = await restarted.getAuthorization(); assert.equal(grant.state === "trial" && grant.remaining, 1); const issued = await h.issue(); await restarted.activate(issued.key); await restarted.refresh(); - assert.equal((await storage.load())?.trialConsumed, 2); + assert.equal((await storage.load())?.importUsage.trialConsumed, 2); await restarted.deactivate(); assert.equal((await restarted.getAuthorization()).state, "unlicensed"); - assert.equal((await restarted.startTrial()).state, "trial"); - assert.deepEqual(await restarted.consumeTrial(), { state: "expired", reason: "trial_exhausted" }); - await restarted.startTrial(); - assert.deepEqual(await restarted.getAuthorization(), { - state: "expired", - reason: "trial_exhausted", - }); + await assert.rejects(() => restarted.startTrial(), { code: "trial_converted" }); + assert.equal((await restarted.getAuthorization()).state, "unlicensed"); const spent = new KeyForgeClient( h.options(new MemoryStorage(), { registration: { machineSignals: signals } }), ); @@ -1009,7 +1041,7 @@ test("SDK rejects clock rollback, storage corruption/context mixup and unsafe UR await assert.rejects(() => mixed.register(), { code: "storage_context_mismatch" }); const invalidStorage: SecureStorage = { load: async () => ({ version: 2 }) as unknown as ClientState, - save: async () => {}, + compareAndSwap: async () => true, }; await assert.rejects(() => new KeyForgeClient(h.options(invalidStorage)).register(), { code: "invalid_stored_state", @@ -1018,9 +1050,9 @@ test("SDK rejects clock rollback, storage corruption/context mixup and unsafe UR const concurrent = new KeyForgeClient(h.options()); await Promise.all([concurrent.register(), concurrent.register(), concurrent.startTrial()]); await Promise.all([ - concurrent.consumeTrial(), - concurrent.consumeTrial(), - concurrent.consumeTrial(), + concurrent.consumeImport(randomUUID()), + concurrent.consumeImport(randomUUID()), + concurrent.consumeImport(randomUUID()), ]); const grant = await concurrent.getAuthorization(); assert.equal(grant.state === "trial" && grant.remaining, 19997); @@ -1107,7 +1139,7 @@ test("Node reference uses real HTTP and SDK enforces durable identity before sen let calls = 0; const failing: SecureStorage = { load: async () => null, - save: async () => { + compareAndSwap: async () => { throw new Error("vault unavailable"); }, }; diff --git a/test/create-keyforge.test.ts b/test/create-keyforge.test.ts index fd9f244..62e8bde 100644 --- a/test/create-keyforge.test.ts +++ b/test/create-keyforge.test.ts @@ -303,6 +303,7 @@ test("scaffolds a working project from a local template", async (t) => { "Dockerfile", "docs/keyforge.md", "docs/examples/keyforge.config.example.json", + "docs/examples/client-contract/manifest.json", ]) { assert.ok(existsSync(join(directory, kept)), `${kept} should be scaffolded`); } @@ -322,7 +323,6 @@ test("scaffolds a working project from a local template", async (t) => { ); for (const upstreamReview of [ "docs/studio-proof-contract.md", - "docs/examples/studio-proof-contract", "test/integration-contract.test.ts", ]) assert.equal(existsSync(join(directory, upstreamReview)), false, upstreamReview); diff --git a/test/import-licensing.test.ts b/test/import-licensing.test.ts index 9a0573e..ea80818 100644 --- a/test/import-licensing.test.ts +++ b/test/import-licensing.test.ts @@ -142,7 +142,7 @@ test("additive purchases, paid/trial separation, rollover, idempotency, payment client = new KeyForgeClient(h.options(storage)); const trial = await client.startTrial(); assert.equal(trial.state, "trial"); - if (trial.state === "trial") assert.equal(trial.token.v, 3); + if (trial.state === "trial") assert.equal(trial.token.v, 1); const trialEvent = randomUUID(); await client.consumeImport(trialEvent, 12000); assert.equal((await client.consumeImport(trialEvent, 12000)).recorded, false); @@ -366,11 +366,10 @@ test("signed ordering rejects downgrade and conflicting same-revision grants; cr ); for (const outcome of outcomes) if (outcome.status === "rejected") assert.equal(outcome.reason.code, "storage_conflict"); - const noCAS = { load: () => storage.load(), save: (s: any) => storage.save(s) }; - await assert.rejects( - () => new KeyForgeClient(h.options(storage, { storage: noCAS })).consumeImport(randomUUID()), - { code: "atomic_storage_required" }, - ); + const noCAS = { load: () => storage.load() } as any; + assert.throws(() => new KeyForgeClient(h.options(storage, { storage: noCAS })), { + code: "invalid_configuration", + }); const context = { ...h.options(storage), installationId: (await storage.load())!.installationId, @@ -403,60 +402,12 @@ test("new identities cannot claim old import accounts; renewal rollback and revo const other = new KeyForgeClient(h.options()); await assert.rejects(() => other.activate(issued.key), { code: "recovery_required" }); const state = new MemoryStorage(), - legacyClient = new KeyForgeClient(h.options(state)); - await legacyClient.register(); + freshClient = new KeyForgeClient(h.options(state)); + await freshClient.register(); const stored = (await state.load())!; - delete stored.importUsage; + delete (stored as any).importUsage; await state.save(stored); - await assert.rejects(() => legacyClient.activate(issued.key), { code: "recovery_required" }); -}); - -test("legacy adoption preserves finance and requires explicit verified local usage", async (t) => { - const h = await fixture(t), - issued = await h.issue(), - s = "product:default"; - const { importAccountId: _account, ...legacy } = (await h.records.get( - s, - "license", - issued.license.id, - ))!; - await h.records.put(s, "license", legacy); - const storage = new MemoryStorage(), - client = new KeyForgeClient(h.options(storage)); - await client.activate(issued.key); - const state = (await storage.load())!; - delete state.importUsage; - await storage.save(state); - const before = await h.admin("GET", "/finance"); - const grant = (await h.admin("GET", "/licenses/" + legacy.id)).license; - await h.admin("POST", "/licenses/" + legacy.id + "/import-account", { - requestId: randomUUID(), - revision: grant.revision, - granted: 50000, - reason: "Reconciled existing allowance without a new sale", - }); - await assert.rejects(() => client.refresh(), { code: "usage_migration_required" }); - assert.equal((await client.getAuthorization()).state, "reconnect_required"); - await client.adoptLegacyImportUsage({ - lifetimeImports: 12000, - paidConsumed: 12000, - trialConsumed: 0, - lifetimeKnown: true, - }); - const migrated = await client.refresh(); - assert.equal(migrated.state, "licensed"); - if (migrated.state === "licensed") assert.equal(migrated.remainingImports, 38000); - assert.deepEqual((await h.admin("GET", "/finance")).sales, before.sales); - await assert.rejects( - () => - client.adoptLegacyImportUsage({ - lifetimeImports: 0, - paidConsumed: 0, - trialConsumed: 0, - lifetimeKnown: true, - }), - { code: "usage_already_initialized" }, - ); + await assert.rejects(() => freshClient.activate(issued.key), { code: "invalid_stored_state" }); }); test("altered recovery signatures and expired approvals cannot replace local usage", async (t) => { diff --git a/test/integration-contract.test.ts b/test/integration-contract.test.ts index 982fde6..8dd2213 100644 --- a/test/integration-contract.test.ts +++ b/test/integration-contract.test.ts @@ -1,87 +1,56 @@ import assert from "node:assert/strict"; -import { createHash, createPublicKey } from "node:crypto"; +import { createHash, createPrivateKey, sign } from "node:crypto"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { - KeyForgeClient, - MemoryStorage, - verifySignedToken, - type ClientState, -} from "../packages/keyforge-client/src/index.js"; - -const root = new URL("../docs/examples/studio-proof-contract/", import.meta.url); -const read = (name: string) => readFileSync(new URL(name, root), "utf8"); -const fixtures = JSON.parse(read("fixtures.json")); - -test("frozen integration package checksums and production public trust remain intact", () => { - const manifest = JSON.parse(read("manifest.json")); - for (const [name, digest] of Object.entries(manifest.sha256)) - assert.equal(createHash("sha256").update(read(name)).digest("hex"), digest, name); - const deployment = JSON.parse(read("deployment.json")); - for (const pem of Object.values(deployment.publicKeys)) +import { verifySignedToken, verifyRecoveryToken } from "../packages/keyforge-client/src/index.js"; +import { productTokenSchema, recoveryTokenSchema } from "../src/token-contract.js"; +const root = new URL("../docs/examples/client-contract/", import.meta.url); +const fixtures = JSON.parse(readFileSync(new URL("fixtures.json", root), "utf8")); +test("portable v1 vectors verify current artifacts and checksums", async () => { + const manifest = JSON.parse(readFileSync(new URL("manifest.json", root), "utf8")); + for (const [file, hash] of Object.entries(manifest.sha256)) assert.equal( createHash("sha256") - .update(createPublicKey(pem as string).export({ type: "spki", format: "der" })) - .digest("hex"), - deployment.spkiSha256, - ); - assert.equal(fixtures.testOnly, true); - assert.equal(fixtures.context.productId, "fixture-product"); - assert.ok(!JSON.stringify(fixtures).includes("PRIVATE KEY")); - for (const pem of Object.values(fixtures.context.publicKeys)) - assert.notEqual( - createHash("sha256") - .update(createPublicKey(pem as string).export({ type: "spki", format: "der" })) + .update(readFileSync(new URL(file, root))) .digest("hex"), - deployment.spkiSha256, - "Test and production trust must remain separate.", + hash, ); -}); - -test("portable signed vectors reproduce verification and offline authorization in the SDK", async (t) => { - for (const item of fixtures.cases) { - await t.test(item.id, async () => { - const context = { ...fixtures.context, now: Date.parse(fixtures.clock) }; - if (item.expected.verification !== "valid") { - await assert.rejects(() => verifySignedToken(item.token, context), { - code: item.expected.verification, - }); - return; - } - const grant = await verifySignedToken(item.token, context); - assert.deepEqual(grant, item.payload); - const storage = new MemoryStorage(); - const state: ClientState = { - version: 1, - serverUrl: "https://fixture.example.test", - productId: context.productId, - subjectKind: "installation", - installationId: context.installationId, - credential: "A".repeat(43), // In-memory test value, never sent or accepted as secure entropy. - registration: { - installationId: context.installationId, - requestId: "22222222-2222-4222-8222-222222222222", - }, - registered: true, - trialStarted: grant.kind === "trial", - trialConsumed: 0, - trialToken: grant.kind === "trial" ? item.token : null, - licenseToken: grant.kind === "license" ? item.token : null, - denial: null, - lastObservedTime: context.now, - }; - await storage.save(state); - const client = new KeyForgeClient({ - ...context, - serverUrl: state.serverUrl, - storage, - now: () => context.now, - fetch: async () => { - throw new Error("Offline vectors must never make a network request."); - }, + for (const sample of fixtures.samples) { + const context = { ...fixtures.context, publicKeys: fixtures.publicKeys }; + if (sample.payload.kind === "usage-recovery") { + assert.deepEqual(await verifyRecoveryToken(sample.token, context), sample.payload); + assert.ok(recoveryTokenSchema.safeParse(sample.payload).success); + await assert.rejects(() => verifySignedToken(sample.token, context), { + code: "unexpected_token_kind", }); - assert.equal((await client.getAuthorization()).state, item.expected.authorization); - }); + } else { + assert.deepEqual(await verifySignedToken(sample.token, context), sample.payload); + assert.ok(productTokenSchema.safeParse(sample.payload).success); + } } }); +test("server and SDK reject every discarded version even with a valid signature", async () => { + const key = createPrivateKey({ + key: Buffer.concat([ + Buffer.from("302e020100300506032b657004220420", "hex"), + Buffer.alloc(32, 73), + ]), + format: "der", + type: "pkcs8", + }); + for (const sample of fixtures.samples) + for (const v of [0, 2, 3, 4, 5]) { + const payload = { ...sample.payload, v }, + bytes = Buffer.from(JSON.stringify(payload)), + token = bytes.toString("base64url") + "." + sign(null, bytes, key).toString("base64url"); + assert.equal(productTokenSchema.safeParse(payload).success, false); + assert.equal(recoveryTokenSchema.safeParse(payload).success, false); + const verify = + sample.payload.kind === "usage-recovery" ? verifyRecoveryToken : verifySignedToken; + await assert.rejects( + () => verify(token, { ...fixtures.context, publicKeys: fixtures.publicKeys }), + { code: "invalid_payload" }, + ); + } +}); diff --git a/test/issuance.test.ts b/test/issuance.test.ts index 7642fd1..5038d28 100644 --- a/test/issuance.test.ts +++ b/test/issuance.test.ts @@ -14,14 +14,8 @@ import { generateSigningKeyPair } from "../src/sign.js"; const today = "2026-10-15"; async function fixture(t: any) { const database = await LicenseDatabase.open({ sqlitePath: ":memory:" }); - const legacy = await database.createLicense({ - plan: "standard", - issuedTo: "Existing buyer", - activationLimit: 1, - entitlements: {}, - }); - const pair = generateSigningKeyPair(), - ring = { active: "test", keys: { test: randomBytes(32).toString("hex") } }; + const pair = generateSigningKeyPair(); + const ring = { active: "test", keys: { test: randomBytes(32).toString("hex") } }; const app = await buildLicenseApp({ database, privateKey: pair.privateKey, @@ -57,7 +51,7 @@ async function fixture(t: any) { ...payload, }); const records = new Records(database.db); - return { app, database, ring, pair, legacy, admin, issue, records }; + return { app, database, ring, pair, admin, issue, records }; } test("issuance snapshots plan terms and ETB price, records an unpaid sale exactly once, and preserves existing records", async (t) => { const h = await fixture(t), @@ -88,8 +82,6 @@ test("issuance snapshots plan terms and ETB price, records an unpaid sale exactl const finance = (await h.admin("GET", "/finance")).json(); assert.equal(finance.sales.length, 1); assert.equal(finance.entries.length, 0); - assert.equal(finance.licenses.find((v: any) => v.id === h.legacy.license.id).hasSale, false); - assert.deepEqual(await h.database.getLicense(h.legacy.license.id), h.legacy.license); const exported = (await h.admin("GET", "/catalog/export")).json(); assert.equal(exported.plans.find((v: any) => v.id === "standard").price, 24000); const preview = ( @@ -132,7 +124,7 @@ test("already-paid issuance is atomic and later payments settle an existing sale assert.equal(finance.sales.length, 2); assert.equal(finance.entries.length, 2); assert.ok(finance.sales.every((v: any) => v.status === "paid")); - assert.equal(finance.licenses.length, 3); // includes the untouched historical license + assert.equal(finance.licenses.length, 2); // includes the untouched historical license const report = (await h.admin("GET", "/finance/report")).json(); assert.equal(report.salesValue, 24000); assert.equal(report.received, 24000); @@ -145,7 +137,7 @@ test("already-paid issuance is atomic and later payments settle an existing sale const broken = await h.issue({ payment: { date: today, method: "Cash", reference: "ROLLBACK" } }); assert.equal(broken.statusCode, 500); assert.equal((await h.records.list("product:default", "sale")).length, 2); - assert.equal((await h.records.list("product:default", "license")).length, 3); + assert.equal((await h.records.list("product:default", "license")).length, 2); }); test("unconfigured prices and invalid payments cannot silently create sales or licenses", async (t) => { const h = await fixture(t); diff --git a/test/keypair.test.ts b/test/keypair.test.ts index b4ebb1a..d09f81f 100644 --- a/test/keypair.test.ts +++ b/test/keypair.test.ts @@ -1,14 +1,22 @@ import assert from "node:assert/strict"; -import { generateKeyPairSync } from "node:crypto"; +import { generateKeyPairSync, sign } from "node:crypto"; import test from "node:test"; +import type { z } from "zod"; + import { describeSigningKey, fingerprintPublicKey, resolveSigningKey, verifyLicenseToken, } from "../src/keypair.js"; -import { generateSigningKeyPair, signLicenseToken, type LicenseTokenV2 } from "../src/sign.js"; +import { generateSigningKeyPair } from "../src/sign.js"; +import type { licenseTokenSchema } from "../src/token-contract.js"; +type LicenseTokenPayload = z.infer; +function signLicenseToken(payload: LicenseTokenPayload, key: string) { + const bytes = Buffer.from(JSON.stringify(payload)); + return bytes.toString("base64url") + "." + sign(null, bytes, key).toString("base64url"); +} /** * The signing key, and the ways it goes wrong. @@ -21,10 +29,19 @@ import { generateSigningKeyPair, signLicenseToken, type LicenseTokenV2 } from ". const ed = generateSigningKeyPair(); -/** A v2 token, which is the version that carries both a kid and entitlements. */ -function payload(overrides: Partial = {}): LicenseTokenV2 { +/** Current v1 token with explicit product/subject and authorization context. */ +function payload(overrides: Partial = {}): LicenseTokenPayload { return { - v: 2, + v: 1, + kind: "license", + iss: "default", + productId: "default", + subject: { kind: "installation", id: "5f3a" }, + accountId: null, + authorizationRevision: 1, + paidImportsGranted: null, + recoveryGeneration: 0, + trialConverted: true, kid: "test-key", licenseId: "9c1e4d2a", plan: "pro", @@ -100,7 +117,7 @@ test("a token verifies against its own key and against no other", () => { const mine = verifyLicenseToken(token, info.publicKeyPem); assert.equal(mine.valid, true); - assert.deepEqual((mine.payload as LicenseTokenV2).entitlements, { seats: 5 }); + assert.deepEqual((mine.payload as LicenseTokenPayload).entitlements, { seats: 5 }); const theirs = verifyLicenseToken(token, generateSigningKeyPair().publicKey); assert.equal(theirs.valid, false); @@ -118,7 +135,7 @@ test("a tampered payload does not verify, even though it still decodes", () => { ).toString("base64url"); const result = verifyLicenseToken(`${forged}.${signature}`, ed.publicKey); assert.equal(result.valid, false); - assert.deepEqual((result.payload as LicenseTokenV2).entitlements, { seats: 5000 }); + assert.deepEqual((result.payload as LicenseTokenPayload).entitlements, { seats: 5000 }); }); test("malformed tokens are reported as answers, not thrown as errors", () => { diff --git a/test/maintenance.test.ts b/test/maintenance.test.ts index cc6f128..0d211ce 100644 --- a/test/maintenance.test.ts +++ b/test/maintenance.test.ts @@ -27,6 +27,7 @@ import { workspacePath, } from "../src/maintenance.js"; import { generateSigningKeyPair } from "../src/sign.js"; +import { seedLicense } from "./record-fixtures.js"; async function fixture(t: any) { const root = await mkdtemp(join(tmpdir(), "kf-maintenance-")); t.after(() => rm(root, { recursive: true, force: true })); @@ -44,7 +45,7 @@ async function fixture(t: any) { test("SQLite reset removes old ledger/operators, recreates schemas and preserves external secrets", async (t) => { const h = await fixture(t), db = await LicenseDatabase.open({ sqlitePath: h.env.LICENSE_DB_PATH }); - await db.createLicense({ + await seedLicense(db, { plan: "old", entitlements: {}, issuedTo: "Old customer", @@ -64,7 +65,16 @@ test("SQLite reset removes old ledger/operators, recreates schemas and preserves assert.equal(before.unrelated.length, 0); await resetAppDatabase(h); const fresh = await LicenseDatabase.open({ sqlitePath: h.env.LICENSE_DB_PATH }); - assert.equal((await fresh.db.selectFrom("licenses").selectAll().execute()).length, 0); + assert.equal( + ( + await fresh.db + .selectFrom("platform_records") + .selectAll() + .where("kind", "=", "license") + .execute() + ).length, + 0, + ); await fresh.close(); const auth = createAuth({ dbPath: h.env.LICENSE_DB_PATH, @@ -116,7 +126,7 @@ test("local cleanup preserves an aliased active database and removes only obsole const obsolete = join(h.root, "db/old.sqlite"), unrelated = join(h.root, "db/other.sqlite"); const old = new SQLite(obsolete); - old.exec("CREATE TABLE licenses (id TEXT)"); + old.exec("CREATE TABLE platform_records (id TEXT)"); old.close(); const other = new SQLite(unrelated); other.exec("CREATE TABLE other_application (id TEXT)"); @@ -248,12 +258,21 @@ test("Postgres database reset drops and recreates only its explicitly confirmed }; await resetAppDatabase({ ...options, scope: "app" }); const db = await LicenseDatabase.open({ url: url.href }); - await db.createLicense({ plan: "old", entitlements: {}, issuedTo: "Old", activationLimit: 1 }); + await seedLicense(db, { plan: "old", entitlements: {}, issuedTo: "Old", activationLimit: 1 }); await db.close(); await resetAppDatabase(options); const fresh = await LicenseDatabase.open({ url: url.href }); try { - assert.equal((await fresh.db.selectFrom("licenses").selectAll().execute()).length, 0); + assert.equal( + ( + await fresh.db + .selectFrom("platform_records") + .selectAll() + .where("kind", "=", "license") + .execute() + ).length, + 0, + ); } finally { await fresh.close(); } diff --git a/test/record-fixtures.ts b/test/record-fixtures.ts new file mode 100644 index 0000000..e18026c --- /dev/null +++ b/test/record-fixtures.ts @@ -0,0 +1,32 @@ +import { randomUUID } from "node:crypto"; + +import { + generateProductKey, + hashProductKey, + type LicenseDatabase, + type LicenseRow, +} from "../src/db.js"; +import { Records } from "../src/platform-store.js"; +/** Scoped records for storage/reset tests; business workflows use the HTTP API. */ +export async function seedLicense(database: LicenseDatabase, input: Partial = {}) { + const key = generateProductKey(); + const license = { + id: randomUUID(), + keyHash: hashProductKey(key), + plan: "standard", + entitlements: {}, + issuedTo: "Fixture customer", + status: "active" as const, + activationLimit: 1, + issuedAt: new Date().toISOString(), + expiresAt: null, + revokedAt: null, + productId: "default", + keyReference: "fixture", + subjectKind: "installation" as const, + revision: 1, + ...input, + }; + await new Records(database.db).put("product:default", "license", license); + return { key, license }; +} diff --git a/test/replica.test.ts b/test/replica.test.ts index b51fc2e..a2a0a73 100644 --- a/test/replica.test.ts +++ b/test/replica.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -37,19 +37,7 @@ const ADMIN = "test-admin-token"; const { privateKey } = generateSigningKeyPair(); const auth = { authorization: `Bearer ${ADMIN}` }; -const LEDGER = [ - "platform_records", - "platform_lock", - "licenses", - "installations", - "activations", - "machine_groups", - "machine_signals", - "machine_installations", - "audit_log", - "support_requests", - "support_rate_limit_buckets", -] as const; +const LEDGER = ["platform_records", "platform_lock", "audit_log"] as const; function tempDir(t: TestContext): string { const dir = mkdtempSync(join(tmpdir(), "keyforge-replica-")); @@ -60,57 +48,6 @@ function tempDir(t: TestContext): string { /** A ledger with something in every table, produced the way a vendor would. */ async function seed(sqlitePath: string): Promise { const database = await LicenseDatabase.open({ sqlitePath }); - // Historical tables must survive upgrades and database copies too. - const first = await database.createLicense({ - plan: "standard", - activationLimit: 2, - entitlements: { seats: 3 }, - }); - for (const installationId of ["machine-1", "machine-2"]) { - await database.registerInstallation({ installationId, appVersion: "1.4.0", platform: "linux" }); - await database.recordTrialRegistration({ - installationId, - machineSignals: [ - { - kind: "machine-guid", - hash: createHash("sha256") - .update("guid-" + installationId) - .digest("hex"), - }, - { - kind: "smbios-uuid", - hash: createHash("sha256") - .update("bios-" + installationId) - .digest("hex"), - }, - ], - }); - await database.bindActivation(first.license.id, installationId); - await database.linkInstallationToLicense(installationId, first.license.id); - } - await database.ingestSupportRequest({ - licenseId: first.license.id, - installationId: "machine-1", - request: { - clientRequestId: randomUUID(), - category: "technical", - urgency: "normal", - subject: "Historical support", - message: "Retain this request across a database copy.", - reporterName: null, - appVersion: null, - platform: null, - clientCreatedAt: new Date().toISOString(), - }, - now: new Date(), - maxRequests: 5, - windowMs: 3600000, - }); - await database.appendAudit({ - actor: "fixture", - action: "legacy.seed", - licenseId: first.license.id, - }); const app = await buildLicenseApp({ database, privateKey, @@ -139,6 +76,11 @@ async function seed(sqlitePath: string): Promise { }, }); assert.equal(response.statusCode, 201, response.body); + await database.appendAudit({ + actor: "fixture", + action: "license.issue", + licenseId: response.json().license.id, + }); } finally { await app.close(); await database.close(); @@ -293,14 +235,6 @@ test("a destination that already holds data is not overwritten by accident", asy // With --replace it becomes the source exactly: no leftovers, no orphans. await copyLedger({ from: resolveTarget(other), to: resolveTarget(standby), replace: true }); assert.deepEqual(await dump(standby), await dump(other)); - const orphans = await dump(standby); - const ids = new Set(orphans.licenses.map((row) => (row as { id: string }).id)); - for (const row of orphans.activations) { - assert.ok( - ids.has((row as { license_id: string }).license_id), - "no activation may outlive its license", - ); - } }); /** @@ -353,25 +287,27 @@ test("verify names what differs instead of just failing", async (t) => { // One edited row — the case a row count alone would call identical. const handle = connectDatabase(resolveTarget(standby)); - await sql`UPDATE licenses SET issued_to = 'Someone Else'`.execute(handle.db); + await sql`UPDATE platform_records SET data = replace(data, 'Ada Lovelace', 'Someone Else') WHERE kind = 'license'`.execute( + handle.db, + ); await handle.close(); const result = await verifyLedger({ from: resolveTarget(origin), to: resolveTarget(standby) }); assert.equal(result.match, false); - const licenses = result.tables.find((verdict) => verdict.table === "licenses"); + const licenses = result.tables.find((verdict) => verdict.table === "platform_records"); assert.equal(licenses?.match, false); assert.equal(licenses?.from, licenses?.to, "counts match; only the contents differ"); - assert.match(String(licenses?.difference), /issued_to/); + assert.match(String(licenses?.difference), /data/); // The other tables are untouched and must not be reported as suspect. assert.equal(result.tables.find((verdict) => verdict.table === "audit_log")?.match, true); // A missing row is located too. const second = connectDatabase(resolveTarget(standby)); - await sql`DELETE FROM activations`.execute(second.db); + await sql`DELETE FROM platform_records WHERE kind = 'license'`.execute(second.db); await second.close(); const after = await verifyLedger({ from: resolveTarget(origin), to: resolveTarget(standby) }); assert.match( - String(after.tables.find((verdict) => verdict.table === "activations")?.difference), + String(after.tables.find((verdict) => verdict.table === "platform_records")?.difference), /missing from the destination/, ); }); diff --git a/test/roles.test.ts b/test/roles.test.ts index 1a5651e..ab38598 100644 --- a/test/roles.test.ts +++ b/test/roles.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import SQLite from "better-sqlite3"; import type { FastifyInstance } from "fastify"; import { buildLicenseApp } from "../src/app.js"; @@ -217,11 +216,6 @@ test("support is refused every license mutation, admin is allowed", async () => method: "GET" as const, url: `/admin/products/default/licenses/${licenseId}/import-account`, }, - { - method: "POST" as const, - url: `/admin/products/default/licenses/${licenseId}/import-account`, - payload: {}, - }, { method: "POST" as const, url: `/admin/products/default/licenses/${licenseId}/renew`, @@ -423,47 +417,6 @@ test("/admin/config reports the viewer's own role", async () => { } }); -test("operators created before roles existed are restored to admin, not demoted", async () => { - // A pre-M3 auth database: Better Auth's own tables, no role column at all. - const legacyPath = join(tmpdir(), `keyforge-legacy-auth-${randomUUID()}.sqlite`); - const legacy = new SQLite(legacyPath); - legacy.exec(` - CREATE TABLE keyforge_auth_user ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - emailVerified INTEGER NOT NULL DEFAULT 0, - image TEXT, - createdAt DATE NOT NULL, - updatedAt DATE NOT NULL - ); - INSERT INTO keyforge_auth_user (id, name, email, emailVerified, createdAt, updatedAt) - VALUES ('old-1', 'Founder', 'founder@example.com', 1, '2025-01-01', '2025-01-01'); - `); - legacy.close(); - - try { - const auth = createAuth({ dbPath: legacyPath, secret: SECRET, baseURL: BASE_URL }); - const promoted = await ensureAuthSchema(auth); - assert.equal(promoted, 1, "the pre-roles operator should be promoted"); - - const context = await auth.$context; - const user = (await context.adapter.findOne({ - model: "user", - where: [{ field: "email", value: "founder@example.com" }], - })) as { role?: string } | null; - assert.equal(user?.role, "admin", "an operator who had full access must keep it"); - - // Booting again promotes nobody — the backfill is idempotent. - assert.equal(await ensureAuthSchema(auth), 0); - await closeAuth(auth); - } finally { - for (const suffix of ["", "-wal", "-shm"]) { - await rm(`${legacyPath}${suffix}`, { force: true }); - } - } -}); - test("auth.admin scope decides which admin endpoints exist", async () => { const core = await makeHarness({ auth: { admin: "core" } }, bootstrap); try { diff --git a/test/scaffold-prompts.test.ts b/test/scaffold-prompts.test.ts index 41ade32..3e3c424 100644 --- a/test/scaffold-prompts.test.ts +++ b/test/scaffold-prompts.test.ts @@ -37,7 +37,7 @@ test("client starter validates configuration and preserves existing applications ); const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); assert.equal(pkg.private, true); - assert.equal(pkg.dependencies["@zdevzone/keyforge-client"], "^0.1.1"); + assert.equal(pkg.dependencies["@zdevzone/keyforge-client"], "^0.2.0"); assert.match( await readFile(join(directory, ".env.example"), "utf8"), /KEYFORGE_PRODUCT_ID=studio/, diff --git a/test/system-reset.test.ts b/test/system-reset.test.ts index 6307b72..48ce630 100644 --- a/test/system-reset.test.ts +++ b/test/system-reset.test.ts @@ -21,6 +21,7 @@ import { createPostgresPool } from "../src/database.js"; import { LicenseDatabase } from "../src/db.js"; import { atomic, DomainError, Records, Vault } from "../src/platform-store.js"; import { generateSigningKeyPair } from "../src/sign.js"; +import { seedLicense } from "./record-fixtures.js"; const password = "Reset-fixture-password-2026!"; const baseURL = "http://localhost:4100"; @@ -89,7 +90,7 @@ async function fixture(t: TestContext, url?: string, verificationUrl?: string) { ]) await r.put("product:default", kind, { id: randomUUID(), fixture: true }); }); - await database.createLicense({ + await seedLicense(database, { plan: "standard", entitlements: {}, issuedTo: "Old customer", @@ -171,7 +172,16 @@ test("online reset preserves the original seeded identity, current password and assert.equal((await f.app.inject({ url: "/admin/config", headers: f.headers })).statusCode, 200); assert.equal((await f.app.inject({ url: "/admin/config", headers: f.second })).statusCode, 401); assert.equal((await f.app.inject({ url: "/admin/config", headers: f.lesser })).statusCode, 401); - assert.equal((await f.database.db.selectFrom("licenses").selectAll().execute()).length, 0); + assert.equal( + ( + await f.database.db + .selectFrom("platform_records") + .selectAll() + .where("kind", "=", "license") + .execute() + ).length, + 0, + ); const kinds = await f.database.db .selectFrom("platform_records") .select("kind") @@ -196,7 +206,7 @@ test("online reset preserves the original seeded identity, current password and await readFile(join(f.root, "signing.key"), "utf8"), "external key material stays outside reset", ); - await f.database.createLicense({ + await seedLicense(f.database, { plan: "new", entitlements: {}, issuedTo: "New customer", @@ -204,7 +214,16 @@ test("online reset preserves the original seeded identity, current password and }); const replay = await f.reset(false, requestId); assert.equal(replay.json().replayed, true); - assert.equal((await f.database.db.selectFrom("licenses").selectAll().execute()).length, 1); + assert.equal( + ( + await f.database.db + .selectFrom("platform_records") + .selectAll() + .where("kind", "=", "license") + .execute() + ).length, + 1, + ); await seedOperator({ ...f.options, email: "second@example.test" }); assert.equal( (await f.app.inject({ url: "/admin/system/reset", headers: f.headers })).json().preservedAdmin @@ -238,7 +257,16 @@ test("reset requires an admin, same-origin request, exact confirmation, and a kn false, ); assert.equal((await f.reset()).json().error, "reset_unavailable"); - assert.equal((await f.database.db.selectFrom("licenses").selectAll().execute()).length, 1); + assert.equal( + ( + await f.database.db + .selectFrom("platform_records") + .selectAll() + .where("kind", "=", "license") + .execute() + ).length, + 1, + ); }); test("reset backup is verified before deletion and remains downloadable with the complete original database", async (t) => { @@ -263,7 +291,14 @@ test("reset backup is verified before deletion and remains downloadable with the (restored.prepare(`SELECT count(*) AS n FROM ${AUTH_PREFIX}user`).get() as any).n, 3, ); - assert.equal((restored.prepare("SELECT count(*) AS n FROM licenses").get() as any).n, 1); + assert.equal( + ( + restored + .prepare("SELECT count(*) AS n FROM platform_records WHERE kind = 'license'") + .get() as any + ).n, + 1, + ); assert.equal(restored.pragma("integrity_check", { simple: true }), "ok"); } finally { restored.close(); @@ -278,12 +313,21 @@ test("backup verification failure and database deletion failure both preserve ex assert.equal((await f.reset(true)).json().error, "backup_corrupt"); assert.equal((await readdir(join(f.root, "backups"))).length, 0); mock.mock.restore(); - await sql`CREATE TRIGGER reject_reset BEFORE DELETE ON licenses BEGIN SELECT RAISE(ABORT, 'fixture reset failure'); END`.execute( + await sql`CREATE TRIGGER reject_reset BEFORE DELETE ON platform_records BEGIN SELECT RAISE(ABORT, 'fixture reset failure'); END`.execute( f.database.db, ); const failed = await f.reset(true); assert.equal(failed.statusCode, 500, failed.body); - assert.equal((await f.database.db.selectFrom("licenses").selectAll().execute()).length, 1); + assert.equal( + ( + await f.database.db + .selectFrom("platform_records") + .selectAll() + .where("kind", "=", "license") + .execute() + ).length, + 1, + ); assert.equal((await f.app.inject({ url: "/admin/config", headers: f.second })).statusCode, 200); assert.equal( (await f.app.inject({ url: "/admin/backups", headers: f.headers })).json().jobs.length, @@ -344,10 +388,19 @@ test("Postgres online reset retains the seeded admin and a restore-verified arch assert.ok(result.json().backupId); assert.equal((await f.app.inject({ url: "/admin/config", headers: f.headers })).statusCode, 200); assert.equal((await f.app.inject({ url: "/admin/config", headers: f.second })).statusCode, 401); - assert.equal((await f.database.db.selectFrom("licenses").selectAll().execute()).length, 0); + assert.equal( + ( + await f.database.db + .selectFrom("platform_records") + .selectAll() + .where("kind", "=", "license") + .execute() + ).length, + 0, + ); const jobs = (await f.app.inject({ url: "/admin/backups", headers: f.headers })).json().jobs; assert.ok(jobs[0].verifiedAt); - await f.database.createLicense({ + await seedLicense(f.database, { plan: "again", entitlements: {}, issuedTo: "Later customer", @@ -356,5 +409,14 @@ test("Postgres online reset retains the seeded admin and a restore-verified arch const withoutBackup = await f.reset(false); assert.equal(withoutBackup.statusCode, 200, withoutBackup.body); assert.equal(withoutBackup.json().backupId, null); - assert.equal((await f.database.db.selectFrom("licenses").selectAll().execute()).length, 0); + assert.equal( + ( + await f.database.db + .selectFrom("platform_records") + .selectAll() + .where("kind", "=", "license") + .execute() + ).length, + 0, + ); }); diff --git a/test/unified-database.test.ts b/test/unified-database.test.ts index 0e7ec42..ac5f530 100644 --- a/test/unified-database.test.ts +++ b/test/unified-database.test.ts @@ -6,14 +6,18 @@ import { join } from "node:path"; import test from "node:test"; import { promisify } from "node:util"; +import SQLite from "better-sqlite3"; + import { createAuth, closeAuth, ensureAuthSchema } from "../src/auth.js"; import { BackupService } from "../src/backups.js"; import { configSchema } from "../src/config.js"; import { LicenseDatabase } from "../src/db.js"; +import { Records } from "../src/platform-store.js"; import { Vault } from "../src/platform-store.js"; import { Platform } from "../src/platform.js"; import { resolveRuntime } from "../src/runtime.js"; import { generateSigningKeyPair } from "../src/sign.js"; +import { seedLicense } from "./record-fixtures.js"; const secret = "unified-session-test-secret-0123456789abcdef"; async function fixture(t: any) { @@ -45,7 +49,7 @@ test("shared SQLite backups restore auth and licensing together, retaining sessi const cookie = signed.headers.get("set-cookie")!.split(";")[0]; await closeAuth(auth); const database = await LicenseDatabase.open({ sqlitePath: f.ledger }); - const issued = await database.createLicense({ + const issued = await seedLicense(database, { plan: "restore", issuedTo: "Recovery buyer", activationLimit: 1, @@ -112,8 +116,35 @@ test("shared SQLite backups restore auth and licensing together, retaining sessi } const ledger = await LicenseDatabase.open({ sqlitePath: target }); try { - assert.deepEqual(await ledger.getLicense(issued.license.id), issued.license); + assert.deepEqual( + await new Records(ledger.db).get("product:default", "license", issued.license.id), + issued.license, + ); } finally { await ledger.close(); } }); + +test("fresh v1 schema rejects a previous development database without importing it", async (t) => { + const f = await fixture(t); + const old = new SQLite(f.ledger); + old.exec( + "CREATE TABLE schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL); INSERT INTO schema_migrations VALUES ('001_initial', '2026-01-01'); CREATE TABLE licenses (id TEXT); INSERT INTO licenses VALUES ('old-demo')", + ); + old.close(); + await assert.rejects(() => LicenseDatabase.open({ sqlitePath: f.ledger }), /fresh v1 database/); + const check = new SQLite(f.ledger); + try { + assert.deepEqual(check.prepare("SELECT id FROM licenses").all(), [{ id: "old-demo" }]); + assert.equal( + ( + check + .prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='platform_records'") + .get() as { n: number } + ).n, + 0, + ); + } finally { + check.close(); + } +}); diff --git a/ui/src/components.tsx b/ui/src/components.tsx index aa0a272..5325185 100644 --- a/ui/src/components.tsx +++ b/ui/src/components.tsx @@ -211,8 +211,7 @@ export function EntitlementsEditor(props: { }[]; }>(productPath("/catalog")), }); - const definitions = - catalog.data?.definitions.filter((d) => !d.archived && d.type !== "mixed") ?? []; + const definitions = catalog.data?.definitions.filter((d) => !d.archived) ?? []; const update = (index: number, patch: Partial) => { onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row))); }; diff --git a/ui/src/components/ProductKey.tsx b/ui/src/components/ProductKey.tsx index dd0b9eb..f5cfd48 100644 --- a/ui/src/components/ProductKey.tsx +++ b/ui/src/components/ProductKey.tsx @@ -9,7 +9,6 @@ export default function ProductKey({ license: { id: string; keyReference: string; keyStored: boolean }; }) { const [password, setPassword] = useState(""), - [original, setOriginal] = useState(""), [revealed, setRevealed] = useState(""); const action = useAction(); useEffect(() => { @@ -32,11 +31,10 @@ export default function ProductKey({ void action.run(async () => { const result = await request<{ key: string }>( productPath("/licenses/" + license.id + "/key"), - { method: "POST", body: { password, ...(original ? { key: original } : {}) } }, + { method: "POST", body: { password } }, ); setRevealed(result.key); setPassword(""); - setOriginal(""); }, "Key revealed."); }} > @@ -47,22 +45,8 @@ export default function ProductKey({ onChange={setPassword} required /> - {!license.keyStored && ( - <> -

- The original hash-only key cannot be recovered. Re-enter the customer's original key - to retain it securely. -

- - - )} - diff --git a/ui/src/docs/catalog.ts b/ui/src/docs/catalog.ts index e60e737..d0c5ff1 100644 --- a/ui/src/docs/catalog.ts +++ b/ui/src/docs/catalog.ts @@ -122,14 +122,14 @@ export const docCatalog: DocEntry[] = [ title: "Windows adapter reference", group: "Build an integration", source: "docs/windows-device-identity.md", - description: "Windows sources, normalization and compatibility vectors.", + description: "Windows sources, normalization and v1 vectors.", }, { slug: "verify-token", title: "Token verification", group: "Build an integration", source: "docs/verify-token.md", - description: "Ed25519 license v4 and trial v2 verification for any language.", + description: "Ed25519 license, trial and recovery v1 verification for any language.", }, { slug: "dependent-app-ai-context", diff --git a/ui/src/pages/Entitlements.tsx b/ui/src/pages/Entitlements.tsx index 41e9863..285f5d6 100644 --- a/ui/src/pages/Entitlements.tsx +++ b/ui/src/pages/Entitlements.tsx @@ -189,7 +189,7 @@ export default function Entitlements() { function DefinitionEditor({ value, done }: { value: any; done: () => void }) { const [id, setId] = useState(value.id || ""), [description, setDescription] = useState(value.description || ""), - [type, setType] = useState(value.type === "mixed" ? "number" : value.type || "number"), + [type, setType] = useState(value.type || "number"), [defaultValue, setDefault] = useState( value.defaultValue === undefined ? "" : String(value.defaultValue), ), diff --git a/vite.config.ts b/vite.config.ts index eeb1c43..63012f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ "dist", "node_modules", "**/*.md", + "docs/examples/client-contract/*.json", "package-lock.json", "ui/package-lock.json", ".github/",