From 1a0b648abdf82a42fb40a1bf24ba44f095f200a5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 07:41:54 -0700 Subject: [PATCH 01/51] docs: add license verification library implementation plan --- ...2026-04-19-license-verification-library.md | 2084 +++++++++++++++++ 1 file changed, 2084 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-19-license-verification-library.md diff --git a/docs/superpowers/plans/2026-04-19-license-verification-library.md b/docs/superpowers/plans/2026-04-19-license-verification-library.md new file mode 100644 index 000000000..2ab7c6586 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-license-verification-library.md @@ -0,0 +1,2084 @@ +# License Verification Library Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `@cacheplane/licensing` — a pure-TS library that performs offline Ed25519 license verification, surfaces a grace-period + nag UX, and sends non-blocking anonymous telemetry — then wire it into `@cacheplane/angular`, `@cacheplane/render`, and `@cacheplane/chat` so a licensed app initializes silently and an unlicensed app sees a console nudge but still runs. + +**Architecture:** `@cacheplane/licensing` is a framework-agnostic TypeScript library (built with `@nx/js:tsc`, same pattern as `libs/a2ui`) that exposes four seams: + +1. **`verifyLicense(token, publicKeyBytes, now)`** — pure function that decodes a compact Ed25519-signed license token and returns `{ valid, claims?, reason? }`. No I/O, deterministic, trivially testable with fixtures. +2. **`evaluateLicense(result, now, options?)`** — turns a verification result into a `LicenseStatus` (`licensed | grace | expired | missing | tampered | noncommercial`) based on grace period, env hints, and the package's compile-time public key. +3. **`runLicenseCheck(options)`** — init-time side-effect orchestrator: runs `verifyLicense` + `evaluateLicense`, emits the nag UX (`console.warn` with a stable prefix), and kicks off the telemetry client. Idempotent per `(packageName, token)` so the three Angular libraries can each call it without triple-logging. +4. **`createTelemetryClient(options)`** — non-blocking `fetch` POST to the telemetry endpoint with `{ license_id, version, anon_instance_id, package }`. Fire-and-forget, opts out on `CACHEPLANE_TELEMETRY=0` / `globalThis.CACHEPLANE_TELEMETRY === false`, silent on any failure. + +Each Angular library receives an optional `license` field on its existing config (`AgentConfig`, `RenderConfig`, `ChatConfig`). `provideAgent` / `provideRender` / `provideChat` call `runLicenseCheck` exactly once per package per app at provider-construction time. The package's Ed25519 **public key** is embedded as a compile-time constant via a generated `license-public-key.generated.ts` file that is produced by a `prebuild` step and `.gitignore`d. For local development the generator falls back to a committed dev key fixture so library builds never fail on a fresh clone. The release infrastructure plan already reserved a "public-key embedding at build time" task for the production key-injection story; this plan ships the mechanism and leaves key management to that plan. + +**Tech Stack:** TypeScript 5.9 (strict), `@noble/ed25519` for signature verification (small, audited, browser+node), Vitest for unit tests, Nx 22 with `@nx/js:tsc` executor, Angular 21 injection patterns for the three wrapper libraries. + +--- + +## File Structure + +**New library** — `libs/licensing/`: + +- `libs/licensing/package.json` — name `@cacheplane/licensing`, version `0.0.1`, `sideEffects: false`, `publishConfig` with `provenance: true` +- `libs/licensing/project.json` — Nx project (`@nx/js:tsc` build, `@nx/vite:test`, lint) +- `libs/licensing/tsconfig.json`, `tsconfig.lib.json` — mirror `libs/a2ui` +- `libs/licensing/vite.config.mts` — same pattern as `libs/a2ui` +- `libs/licensing/README.md` — short overview + usage +- `libs/licensing/src/index.ts` — public API barrel +- `libs/licensing/src/lib/license-token.ts` — `LicenseClaims` type, `parseLicenseToken` +- `libs/licensing/src/lib/license-token.spec.ts` — parsing tests +- `libs/licensing/src/lib/verify-license.ts` — `verifyLicense`, `VerifyResult` +- `libs/licensing/src/lib/verify-license.spec.ts` — signature tests (valid / tampered / bad format) +- `libs/licensing/src/lib/evaluate-license.ts` — `evaluateLicense`, `LicenseStatus` +- `libs/licensing/src/lib/evaluate-license.spec.ts` — grace / expired / noncommercial tests +- `libs/licensing/src/lib/nag.ts` — `emitNag`, dedupe cache +- `libs/licensing/src/lib/nag.spec.ts` — emission + dedupe tests +- `libs/licensing/src/lib/telemetry.ts` — `createTelemetryClient`, opt-out parsing +- `libs/licensing/src/lib/telemetry.spec.ts` — opt-out + non-blocking behavior +- `libs/licensing/src/lib/run-license-check.ts` — `runLicenseCheck` orchestrator +- `libs/licensing/src/lib/run-license-check.spec.ts` — full integration test +- `libs/licensing/src/lib/license-public-key.ts` — stable re-export of generated key (Task 9) +- `libs/licensing/src/lib/license-public-key.generated.ts` — **gitignored**, produced by prebuild script +- `libs/licensing/src/lib/testing/keypair.ts` — dev/test utilities: `generateKeyPair`, `signLicense` +- `libs/licensing/src/lib/testing/fixtures.ts` — seeded fixture claims (valid, expired, noncommercial) +- `libs/licensing/scripts/generate-public-key.mjs` — prebuild: reads env `CACHEPLANE_LICENSE_PUBLIC_KEY` or falls back to committed dev key +- `libs/licensing/fixtures/dev-public-key.hex` — committed 32-byte hex public key for local dev + +**Modified Angular libraries** — integrate license check into providers: + +- `libs/agent/src/lib/agent.provider.ts` — add `license?: string` to `AgentConfig`; call `runLicenseCheck` inside `provideAgent` +- `libs/agent/src/lib/agent.provider.spec.ts` — tests that config is still honored and license check is invoked +- `libs/render/src/lib/provide-render.ts` — add `license?: string` to `RenderConfig`; call `runLicenseCheck` +- `libs/render/src/lib/render.types.ts` — add `license?: string` on `RenderConfig` +- `libs/render/src/lib/provide-render.spec.ts` — **create**; license check wired +- `libs/chat/src/lib/provide-chat.ts` — add `license?: string` to `ChatConfig`; call `runLicenseCheck` +- `libs/chat/src/lib/provide-chat.spec.ts` — **create**; license check wired + +**Workspace** — wiring: + +- `tsconfig.base.json` — add `"@cacheplane/licensing": ["libs/licensing/src/index.ts"]` path alias +- `libs/agent/package.json` — add `@cacheplane/licensing` to `peerDependencies` with `^0.0.1` +- `libs/render/package.json` — add `@cacheplane/licensing` to `peerDependencies` with `^0.0.1` +- `libs/chat/package.json` — add `@cacheplane/licensing` to `peerDependencies` with `^0.0.1` +- `package.json` — add `@noble/ed25519` to root `dependencies` +- `.gitignore` — add `libs/licensing/src/lib/license-public-key.generated.ts` +- `docs/superpowers/specs/2026-04-17-v1-roadmap-design.md` — no changes (spec already covers this scope) + +**Notes on scope boundaries:** + +- This plan **does not** ship the production public key. The generator consumes `CACHEPLANE_LICENSE_PUBLIC_KEY` at build time and a dev fixture otherwise; wiring the release pipeline to inject the real key is tracked in the release infrastructure plan (task "public key embedding at build time"). +- This plan **does not** ship the minting service (Plan 2) or Stripe integration (Plan 3). +- This plan **does not** change cockpit or example apps — they pick up the new peer dep transitively but don't need code changes. + +--- + +## Task 1: Scaffold `libs/licensing/` project + +**Files:** +- Create: `libs/licensing/package.json` +- Create: `libs/licensing/project.json` +- Create: `libs/licensing/tsconfig.json` +- Create: `libs/licensing/tsconfig.lib.json` +- Create: `libs/licensing/vite.config.mts` +- Create: `libs/licensing/src/index.ts` +- Modify: `tsconfig.base.json` (add path alias) +- Modify: `package.json` (add `@noble/ed25519` dependency) + +- [ ] **Step 1: Write `libs/licensing/package.json`** + +```json +{ + "name": "@cacheplane/licensing", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false, + "publishConfig": { + "access": "public", + "provenance": true + }, + "peerDependencies": { + "@noble/ed25519": "^2.2.3" + } +} +``` + +- [ ] **Step 2: Write `libs/licensing/project.json`** + +```json +{ + "name": "licensing", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/licensing/src", + "projectType": "library", + "tags": ["scope:shared", "type:lib"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/libs/licensing"], + "options": { + "outputPath": "dist/libs/licensing", + "main": "libs/licensing/src/index.ts", + "tsConfig": "libs/licensing/tsconfig.lib.json" + } + }, + "lint": { "executor": "@nx/eslint:lint" }, + "test": { + "executor": "@nx/vite:test", + "options": { "configFile": "libs/licensing/vite.config.mts" } + } + } +} +``` + +- [ ] **Step 3: Write `libs/licensing/tsconfig.json` and `tsconfig.lib.json`** + +`libs/licensing/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} +``` + +`libs/licensing/tsconfig.lib.json`: +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/lib/testing/**"] +} +``` + +- [ ] **Step 4: Write `libs/licensing/vite.config.mts`** + +```ts +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + environment: 'node', + globals: true, + include: ['src/**/*.spec.ts'], + passWithNoTests: true, + }, +}); +``` + +- [ ] **Step 5: Write placeholder `libs/licensing/src/index.ts`** + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Public API — filled in by later tasks. +export {}; +``` + +- [ ] **Step 6: Add path alias in `tsconfig.base.json`** + +Insert one entry into the `paths` object (keep file otherwise unchanged): + +```json +"@cacheplane/licensing": ["libs/licensing/src/index.ts"], +``` + +- [ ] **Step 7: Add `@noble/ed25519` to root `package.json` dependencies** + +Run: +```bash +npm install @noble/ed25519@^2.2.3 +``` +Expected: installs without peer dep warnings. + +- [ ] **Step 8: Verify Nx can see the project** + +Run: `npx nx show project licensing` +Expected: JSON output listing `build`, `lint`, `test` targets. + +- [ ] **Step 9: Verify build succeeds** + +Run: `npx nx build licensing` +Expected: `dist/libs/licensing/` contains `index.js` and `index.d.ts` (empty module is fine). + +- [ ] **Step 10: Commit** + +```bash +git add libs/licensing tsconfig.base.json package.json package-lock.json +git commit -m "feat(licensing): scaffold @cacheplane/licensing library" +``` + +--- + +## Task 2: License token schema and parser + +**Files:** +- Create: `libs/licensing/src/lib/license-token.ts` +- Test: `libs/licensing/src/lib/license-token.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +`libs/licensing/src/lib/license-token.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { parseLicenseToken } from './license-token'; + +const CLAIMS = { + sub: 'cus_123', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 1_800_000_000, + seats: 5, +}; + +function b64url(bytes: Uint8Array | string): string { + const input = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes; + // Node's Buffer is available in vitest `environment: 'node'`. + return Buffer.from(input).toString('base64url'); +} + +describe('parseLicenseToken', () => { + it('splits a valid token into claims + signature bytes', () => { + const payloadJson = JSON.stringify(CLAIMS); + const signatureBytes = new Uint8Array(64).fill(7); + const token = `${b64url(payloadJson)}.${b64url(signatureBytes)}`; + + const result = parseLicenseToken(token); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok'); + expect(result.claims).toEqual(CLAIMS); + expect(result.signature).toEqual(signatureBytes); + expect(result.signedMessage).toEqual(new TextEncoder().encode(payloadJson)); + }); + + it('rejects a token with wrong number of segments', () => { + const result = parseLicenseToken('only-one-segment'); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('malformed'); + }); + + it('rejects a token with non-JSON payload', () => { + const token = `${b64url('not-json')}.${b64url(new Uint8Array(64))}`; + const result = parseLicenseToken(token); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('malformed'); + }); + + it('rejects a token missing required claims', () => { + const payload = JSON.stringify({ sub: 'cus_123' }); + const token = `${b64url(payload)}.${b64url(new Uint8Array(64))}`; + const result = parseLicenseToken(token); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('malformed'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test licensing` +Expected: FAIL — `parseLicenseToken is not a function`. + +- [ ] **Step 3: Implement the parser** + +`libs/licensing/src/lib/license-token.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +/** The tier a license grants. */ +export type LicenseTier = 'developer-seat' | 'app-deployment' | 'enterprise'; + +/** Claims carried inside a signed license token. */ +export interface LicenseClaims { + /** Customer id (Stripe customer). */ + sub: string; + /** Tier the license grants. */ + tier: LicenseTier; + /** Issued-at, epoch seconds. */ + iat: number; + /** Expires-at, epoch seconds. */ + exp: number; + /** Seat count (>=1). */ + seats: number; +} + +export type ParseLicenseTokenResult = + | { + ok: true; + claims: LicenseClaims; + /** Raw bytes that were signed (UTF-8 of the payload segment). */ + signedMessage: Uint8Array; + signature: Uint8Array; + } + | { ok: false; reason: 'malformed' }; + +function base64UrlToBytes(s: string): Uint8Array | null { + try { + // base64url -> base64 + const pad = '='.repeat((4 - (s.length % 4)) % 4); + const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/'); + return Uint8Array.from(Buffer.from(b64, 'base64')); + } catch { + return null; + } +} + +function isLicenseClaims(value: unknown): value is LicenseClaims { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.sub === 'string' && + (v.tier === 'developer-seat' || + v.tier === 'app-deployment' || + v.tier === 'enterprise') && + typeof v.iat === 'number' && + typeof v.exp === 'number' && + typeof v.seats === 'number' && + v.seats >= 1 + ); +} + +/** + * Parse a compact license token of the form `.`. + * Does NOT verify the signature — see {@link verifyLicense}. + */ +export function parseLicenseToken(token: string): ParseLicenseTokenResult { + const parts = token.split('.'); + if (parts.length !== 2) return { ok: false, reason: 'malformed' }; + const [payloadSeg, signatureSeg] = parts; + + const payloadBytes = base64UrlToBytes(payloadSeg); + const signature = base64UrlToBytes(signatureSeg); + if (!payloadBytes || !signature) return { ok: false, reason: 'malformed' }; + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(payloadBytes)); + } catch { + return { ok: false, reason: 'malformed' }; + } + + if (!isLicenseClaims(parsed)) return { ok: false, reason: 'malformed' }; + + return { ok: true, claims: parsed, signedMessage: payloadBytes, signature }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test licensing` +Expected: PASS, 4/4. + +- [ ] **Step 5: Commit** + +```bash +git add libs/licensing/src/lib/license-token.ts libs/licensing/src/lib/license-token.spec.ts +git commit -m "feat(licensing): add license token schema and parser" +``` + +--- + +## Task 3: Ed25519 signature verification + +**Files:** +- Create: `libs/licensing/src/lib/testing/keypair.ts` +- Create: `libs/licensing/src/lib/verify-license.ts` +- Test: `libs/licensing/src/lib/verify-license.spec.ts` + +- [ ] **Step 1: Write the test-only keypair helper (not exported from public API)** + +`libs/licensing/src/lib/testing/keypair.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// TEST-ONLY utility: do not export from the package's public index. +import * as ed from '@noble/ed25519'; +import type { LicenseClaims } from '../license-token'; + +export interface DevKeyPair { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +export async function generateKeyPair(): Promise { + const privateKey = ed.utils.randomPrivateKey(); + const publicKey = await ed.getPublicKeyAsync(privateKey); + return { publicKey, privateKey }; +} + +function b64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64url'); +} + +/** + * Sign claims with the given private key and return a compact token + * `.`. Used by tests and by the + * dev-fixture generator; NOT used at runtime by the package. + */ +export async function signLicense( + claims: LicenseClaims, + privateKey: Uint8Array, +): Promise { + const payload = new TextEncoder().encode(JSON.stringify(claims)); + const sig = await ed.signAsync(payload, privateKey); + return `${b64url(payload)}.${b64url(sig)}`; +} +``` + +- [ ] **Step 2: Write the failing tests** + +`libs/licensing/src/lib/verify-license.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { beforeAll, describe, it, expect } from 'vitest'; +import { verifyLicense } from './verify-license'; +import { generateKeyPair, signLicense, type DevKeyPair } from './testing/keypair'; +import type { LicenseClaims } from './license-token'; + +const BASE_CLAIMS: LicenseClaims = { + sub: 'cus_123', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 1_800_000_000, + seats: 5, +}; + +describe('verifyLicense', () => { + let kp: DevKeyPair; + let otherKp: DevKeyPair; + let validToken: string; + + beforeAll(async () => { + kp = await generateKeyPair(); + otherKp = await generateKeyPair(); + validToken = await signLicense(BASE_CLAIMS, kp.privateKey); + }); + + it('accepts a valid token signed with the matching key', async () => { + const result = await verifyLicense(validToken, kp.publicKey); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok'); + expect(result.claims).toEqual(BASE_CLAIMS); + }); + + it('rejects a token signed with a different key as tampered', async () => { + const badToken = await signLicense(BASE_CLAIMS, otherKp.privateKey); + const result = await verifyLicense(badToken, kp.publicKey); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('tampered'); + }); + + it('rejects a token whose payload has been mutated as tampered', async () => { + const [payload, sig] = validToken.split('.'); + // Flip one byte of the payload to invalidate the signature while + // keeping the shape valid. + const mutated = Buffer.from(payload + 'A', 'base64url'); + const tamperedToken = `${Buffer.from(mutated).toString('base64url')}.${sig}`; + const result = await verifyLicense(tamperedToken, kp.publicKey); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + // Could either fail as malformed (JSON parse) or tampered (sig check). + expect(['malformed', 'tampered']).toContain(result.reason); + }); + + it('rejects a malformed token', async () => { + const result = await verifyLicense('not-a-token', kp.publicKey); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('malformed'); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx nx test licensing` +Expected: FAIL — `verifyLicense is not a function`. + +- [ ] **Step 4: Implement `verifyLicense`** + +`libs/licensing/src/lib/verify-license.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import * as ed from '@noble/ed25519'; +import { parseLicenseToken, type LicenseClaims } from './license-token'; + +export type VerifyReason = 'malformed' | 'tampered'; + +export type VerifyResult = + | { ok: true; claims: LicenseClaims } + | { ok: false; reason: VerifyReason }; + +/** + * Offline-verify a license token against a raw Ed25519 public key. + * No network calls, no time-based checks — see {@link evaluateLicense} + * for grace-period / expiry logic. + */ +export async function verifyLicense( + token: string, + publicKey: Uint8Array, +): Promise { + const parsed = parseLicenseToken(token); + if (!parsed.ok) return { ok: false, reason: 'malformed' }; + + let valid = false; + try { + valid = await ed.verifyAsync(parsed.signature, parsed.signedMessage, publicKey); + } catch { + valid = false; + } + + if (!valid) return { ok: false, reason: 'tampered' }; + return { ok: true, claims: parsed.claims }; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test licensing` +Expected: PASS, 8/8 total across the two spec files. + +- [ ] **Step 6: Commit** + +```bash +git add libs/licensing/src/lib/verify-license.ts libs/licensing/src/lib/verify-license.spec.ts libs/licensing/src/lib/testing/keypair.ts +git commit -m "feat(licensing): add offline ed25519 license verification" +``` + +--- + +## Task 4: `evaluateLicense` — grace period, status, and noncommercial hint + +**Files:** +- Create: `libs/licensing/src/lib/evaluate-license.ts` +- Test: `libs/licensing/src/lib/evaluate-license.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +`libs/licensing/src/lib/evaluate-license.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { evaluateLicense } from './evaluate-license'; +import type { LicenseClaims } from './license-token'; + +const CLAIMS: LicenseClaims = { + sub: 'cus_123', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 2_000_000_000, // far future + seats: 5, +}; + +const DAY = 86_400; + +describe('evaluateLicense', () => { + it('returns licensed when verify ok and not expired', () => { + const result = evaluateLicense( + { ok: true, claims: CLAIMS }, + { nowSec: 1_900_000_000 }, + ); + expect(result.status).toBe('licensed'); + expect(result.claims).toEqual(CLAIMS); + }); + + it('returns grace within 14 days after expiry', () => { + const expired = { ...CLAIMS, exp: 1_900_000_000 }; + const result = evaluateLicense( + { ok: true, claims: expired }, + { nowSec: 1_900_000_000 + 10 * DAY }, + ); + expect(result.status).toBe('grace'); + expect(result.claims).toEqual(expired); + }); + + it('returns expired past the 14 day grace window', () => { + const expired = { ...CLAIMS, exp: 1_900_000_000 }; + const result = evaluateLicense( + { ok: true, claims: expired }, + { nowSec: 1_900_000_000 + 15 * DAY }, + ); + expect(result.status).toBe('expired'); + }); + + it('returns tampered when verify failed with bad signature', () => { + const result = evaluateLicense( + { ok: false, reason: 'tampered' }, + { nowSec: 1_900_000_000 }, + ); + expect(result.status).toBe('tampered'); + }); + + it('returns missing when no token was supplied', () => { + const result = evaluateLicense(undefined, { nowSec: 1_900_000_000 }); + expect(result.status).toBe('missing'); + }); + + it('returns noncommercial when no token and dev env is hinted', () => { + const result = evaluateLicense(undefined, { + nowSec: 1_900_000_000, + isNoncommercial: true, + }); + expect(result.status).toBe('noncommercial'); + }); + + it('still returns licensed in noncommercial env when a valid token is present', () => { + const result = evaluateLicense( + { ok: true, claims: CLAIMS }, + { nowSec: 1_900_000_000, isNoncommercial: true }, + ); + expect(result.status).toBe('licensed'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test licensing` +Expected: FAIL — `evaluateLicense is not a function`. + +- [ ] **Step 3: Implement `evaluateLicense`** + +`libs/licensing/src/lib/evaluate-license.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { LicenseClaims } from './license-token'; +import type { VerifyResult } from './verify-license'; + +export type LicenseStatus = + | 'licensed' // valid signed token, not expired + | 'grace' // valid signed token, expired but within grace window + | 'expired' // valid signed token, past grace window + | 'missing' // no token and not noncommercial + | 'tampered' // token present, failed signature or malformed + | 'noncommercial'; // no token, env looks noncommercial + +export interface EvaluateOptions { + /** Current time in epoch seconds. Injected for testability. */ + nowSec: number; + /** Grace window in seconds after `exp`. Defaults to 14 days. */ + graceSec?: number; + /** If true, missing token resolves to `noncommercial` instead of `missing`. */ + isNoncommercial?: boolean; +} + +export interface EvaluateResult { + status: LicenseStatus; + /** Populated when the token was valid (licensed / grace / expired). */ + claims?: LicenseClaims; +} + +const FOURTEEN_DAYS_SEC = 14 * 24 * 60 * 60; + +export function evaluateLicense( + verifyResult: VerifyResult | undefined, + options: EvaluateOptions, +): EvaluateResult { + const grace = options.graceSec ?? FOURTEEN_DAYS_SEC; + + if (!verifyResult) { + return { status: options.isNoncommercial ? 'noncommercial' : 'missing' }; + } + + if (!verifyResult.ok) { + return { status: 'tampered' }; + } + + const { claims } = verifyResult; + if (options.nowSec <= claims.exp) return { status: 'licensed', claims }; + if (options.nowSec <= claims.exp + grace) return { status: 'grace', claims }; + return { status: 'expired', claims }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test licensing` +Expected: PASS, 15/15 total across all spec files. + +- [ ] **Step 5: Commit** + +```bash +git add libs/licensing/src/lib/evaluate-license.ts libs/licensing/src/lib/evaluate-license.spec.ts +git commit -m "feat(licensing): add license status evaluation with grace window" +``` + +--- + +## Task 5: Nag UX with per-package dedupe + +**Files:** +- Create: `libs/licensing/src/lib/nag.ts` +- Test: `libs/licensing/src/lib/nag.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +`libs/licensing/src/lib/nag.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { emitNag, __resetNagStateForTests } from './nag'; + +describe('emitNag', () => { + const warn = vi.fn(); + + beforeEach(() => { + warn.mockClear(); + __resetNagStateForTests(); + }); + afterEach(() => { + __resetNagStateForTests(); + }); + + it('is silent when status is licensed', () => { + emitNag({ status: 'licensed' }, { package: '@cacheplane/angular', warn }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('is silent when status is noncommercial', () => { + emitNag({ status: 'noncommercial' }, { package: '@cacheplane/angular', warn }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns with a stable prefix when status is missing', () => { + emitNag({ status: 'missing' }, { package: '@cacheplane/angular', warn }); + expect(warn).toHaveBeenCalledTimes(1); + const message = warn.mock.calls[0][0] as string; + expect(message).toContain('[cacheplane]'); + expect(message).toContain('@cacheplane/angular'); + expect(message).toContain('cacheplane.dev/pricing'); + }); + + it('warns differently for grace / expired / tampered', () => { + emitNag({ status: 'grace' }, { package: '@cacheplane/angular', warn }); + emitNag({ status: 'expired' }, { package: '@cacheplane/render', warn }); + emitNag({ status: 'tampered' }, { package: '@cacheplane/chat', warn }); + expect(warn).toHaveBeenCalledTimes(3); + expect(warn.mock.calls[0][0]).toMatch(/grace/i); + expect(warn.mock.calls[1][0]).toMatch(/expired/i); + expect(warn.mock.calls[2][0]).toMatch(/tampered|invalid/i); + }); + + it('dedupes repeated calls for the same package + status', () => { + emitNag({ status: 'missing' }, { package: '@cacheplane/angular', warn }); + emitNag({ status: 'missing' }, { package: '@cacheplane/angular', warn }); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('does not dedupe across different packages', () => { + emitNag({ status: 'missing' }, { package: '@cacheplane/angular', warn }); + emitNag({ status: 'missing' }, { package: '@cacheplane/render', warn }); + expect(warn).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test licensing` +Expected: FAIL — `emitNag is not a function`. + +- [ ] **Step 3: Implement `emitNag`** + +`libs/licensing/src/lib/nag.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { EvaluateResult } from './evaluate-license'; + +export interface EmitNagOptions { + /** Fully-qualified npm package name, e.g. "@cacheplane/angular". */ + package: string; + /** Injected warn channel; defaults to `console.warn`. */ + warn?: (message: string) => void; +} + +const seen = new Set(); + +const MESSAGES: Record = { + missing: + 'no license key detected. The library will keep running, but please get a license at https://cacheplane.dev/pricing', + grace: + 'license is expired and within the 14-day grace window. Renew at https://cacheplane.dev/pricing', + expired: + 'license is expired. The library will keep running, but please renew at https://cacheplane.dev/pricing', + tampered: + 'license signature is invalid or malformed. Download a fresh key from https://cacheplane.dev/pricing', +}; + +export function emitNag( + result: Pick, + options: EmitNagOptions, +): void { + const warn = options.warn ?? ((m: string) => console.warn(m)); + const { status } = result; + if (status === 'licensed' || status === 'noncommercial') return; + + const key = `${options.package}|${status}`; + if (seen.has(key)) return; + seen.add(key); + + const body = MESSAGES[status] ?? 'license check failed.'; + warn(`[cacheplane] ${options.package}: ${body}`); +} + +/** @internal testing hook only. */ +export function __resetNagStateForTests(): void { + seen.clear(); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test licensing` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/licensing/src/lib/nag.ts libs/licensing/src/lib/nag.spec.ts +git commit -m "feat(licensing): add nag UX with per-package dedupe" +``` + +--- + +## Task 6: Non-blocking telemetry client with opt-out + +**Files:** +- Create: `libs/licensing/src/lib/telemetry.ts` +- Test: `libs/licensing/src/lib/telemetry.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +`libs/licensing/src/lib/telemetry.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { createTelemetryClient } from './telemetry'; + +describe('createTelemetryClient', () => { + const origEnv = { ...process.env }; + const origGlobal = (globalThis as Record).CACHEPLANE_TELEMETRY; + + beforeEach(() => { + process.env = { ...origEnv }; + delete process.env.CACHEPLANE_TELEMETRY; + delete (globalThis as Record).CACHEPLANE_TELEMETRY; + }); + + afterEach(() => { + process.env = origEnv; + (globalThis as Record).CACHEPLANE_TELEMETRY = origGlobal; + }); + + it('posts a payload to the endpoint with a generated anon_instance_id', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + + await client.send({ + package: '@cacheplane/angular', + version: '1.0.0', + licenseId: 'cus_123', + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://telemetry.example.com/v1/ping'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body.package).toBe('@cacheplane/angular'); + expect(body.version).toBe('1.0.0'); + expect(body.license_id).toBe('cus_123'); + expect(typeof body.anon_instance_id).toBe('string'); + expect(body.anon_instance_id.length).toBeGreaterThan(0); + }); + + it('reuses the same anon_instance_id across calls from the same client', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + await client.send({ package: '@cacheplane/angular', version: '1.0.0' }); + await client.send({ package: '@cacheplane/angular', version: '1.0.0' }); + + const id1 = JSON.parse(fetchMock.mock.calls[0][1].body as string).anon_instance_id; + const id2 = JSON.parse(fetchMock.mock.calls[1][1].body as string).anon_instance_id; + expect(id1).toBe(id2); + }); + + it('is a no-op when CACHEPLANE_TELEMETRY=0 env is set', async () => { + process.env.CACHEPLANE_TELEMETRY = '0'; + const fetchMock = vi.fn(); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + await client.send({ package: '@cacheplane/angular', version: '1.0.0' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('is a no-op when globalThis.CACHEPLANE_TELEMETRY === false', async () => { + (globalThis as Record).CACHEPLANE_TELEMETRY = false; + const fetchMock = vi.fn(); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + await client.send({ package: '@cacheplane/angular', version: '1.0.0' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('never throws when fetch rejects', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('network down')); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + await expect( + client.send({ package: '@cacheplane/angular', version: '1.0.0' }), + ).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test licensing` +Expected: FAIL — `createTelemetryClient is not a function`. + +- [ ] **Step 3: Implement the telemetry client** + +`libs/licensing/src/lib/telemetry.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +export interface TelemetryEvent { + package: string; + version: string; + licenseId?: string; +} + +export interface TelemetryClient { + send(event: TelemetryEvent): Promise; +} + +export interface CreateTelemetryClientOptions { + endpoint: string; + /** Injected for testability. Defaults to the global `fetch`. */ + fetch?: typeof fetch; + /** Injected for testability. Defaults to `crypto.randomUUID()`. */ + generateInstanceId?: () => string; +} + +function isOptedOut(): boolean { + const envFlag = + typeof process !== 'undefined' && process.env + ? process.env.CACHEPLANE_TELEMETRY + : undefined; + if (envFlag === '0' || envFlag === 'false') return true; + const g = (globalThis as Record).CACHEPLANE_TELEMETRY; + if (g === false || g === 0 || g === '0') return true; + return false; +} + +function defaultInstanceId(): string { + // `crypto.randomUUID` is available in Node 19+, modern browsers, + // and all edge runtimes we target. + return crypto.randomUUID(); +} + +export function createTelemetryClient( + options: CreateTelemetryClientOptions, +): TelemetryClient { + const fetchImpl = options.fetch ?? globalThis.fetch; + const makeId = options.generateInstanceId ?? defaultInstanceId; + const anonInstanceId = makeId(); + + return { + async send(event: TelemetryEvent): Promise { + if (isOptedOut()) return; + if (!fetchImpl) return; + + const body = JSON.stringify({ + package: event.package, + version: event.version, + license_id: event.licenseId, + anon_instance_id: anonInstanceId, + ts: Math.floor(Date.now() / 1000), + }); + + try { + await fetchImpl(options.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + // `keepalive` helps in browser unload paths; harmless elsewhere. + keepalive: true, + }); + } catch { + // Never block the host app on telemetry failure. + } + }, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test licensing` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/licensing/src/lib/telemetry.ts libs/licensing/src/lib/telemetry.spec.ts +git commit -m "feat(licensing): add non-blocking telemetry client with opt-out" +``` + +--- + +## Task 7: Public key embedding mechanism + +**Files:** +- Create: `libs/licensing/fixtures/dev-public-key.hex` +- Create: `libs/licensing/scripts/generate-public-key.mjs` +- Create: `libs/licensing/src/lib/license-public-key.ts` +- Modify: `libs/licensing/project.json` (add `prebuild` target) +- Modify: `.gitignore` (ignore generated key) + +- [ ] **Step 1: Generate and commit a dev-only public key fixture** + +Create a dev keypair once (output only the public key into the repo; discard the private key — nothing in this repo should ever sign with it): + +```bash +node -e "import('@noble/ed25519').then(async ed => { const sk = ed.utils.randomPrivateKey(); const pk = await ed.getPublicKeyAsync(sk); const hex = Buffer.from(pk).toString('hex'); process.stdout.write(hex); })" > libs/licensing/fixtures/dev-public-key.hex +``` + +Expected: writes a 64-char hex string (no trailing newline) into `libs/licensing/fixtures/dev-public-key.hex`. + +- [ ] **Step 2: Write the generator script** + +`libs/licensing/scripts/generate-public-key.mjs`: + +```js +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Emits libs/licensing/src/lib/license-public-key.generated.ts with +// the Ed25519 public key to use at runtime. +// +// Priority: +// 1. env CACHEPLANE_LICENSE_PUBLIC_KEY (hex or base64) — used in release builds +// 2. libs/licensing/fixtures/dev-public-key.hex — used in local dev +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LIB_SRC = resolve(__dirname, '../src/lib'); +const OUT = resolve(LIB_SRC, 'license-public-key.generated.ts'); +const FIXTURE = resolve(__dirname, '../fixtures/dev-public-key.hex'); + +function parseKey(raw) { + const trimmed = raw.trim(); + if (/^[0-9a-fA-F]+$/.test(trimmed)) { + if (trimmed.length !== 64) { + throw new Error(`expected 32-byte hex (64 chars), got ${trimmed.length}`); + } + return Buffer.from(trimmed, 'hex'); + } + // Otherwise try base64 / base64url. + const b64 = trimmed.replace(/-/g, '+').replace(/_/g, '/'); + const buf = Buffer.from(b64, 'base64'); + if (buf.length !== 32) { + throw new Error(`expected 32-byte base64 key, got ${buf.length}`); + } + return buf; +} + +const source = + process.env.CACHEPLANE_LICENSE_PUBLIC_KEY + ? { raw: process.env.CACHEPLANE_LICENSE_PUBLIC_KEY, origin: 'env' } + : { raw: readFileSync(FIXTURE, 'utf8'), origin: 'dev-fixture' }; + +const keyBytes = parseKey(source.raw); +const hex = Buffer.from(keyBytes).toString('hex'); + +mkdirSync(LIB_SRC, { recursive: true }); +writeFileSync( + OUT, + `// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// AUTOGENERATED by libs/licensing/scripts/generate-public-key.mjs — do not edit. +// Source: ${source.origin} +export const LICENSE_PUBLIC_KEY_HEX = '${hex}' as const; +`, +); +console.log(`[licensing] wrote ${OUT} (source: ${source.origin})`); +``` + +- [ ] **Step 3: Write the stable re-export** + +`libs/licensing/src/lib/license-public-key.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { LICENSE_PUBLIC_KEY_HEX } from './license-public-key.generated'; + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +/** Ed25519 public key baked into this build of `@cacheplane/licensing`. */ +export const LICENSE_PUBLIC_KEY: Uint8Array = hexToBytes(LICENSE_PUBLIC_KEY_HEX); +``` + +- [ ] **Step 4: Add the prebuild step to `libs/licensing/project.json`** + +Replace the existing `build` and `test` target blocks so every consumer path runs the generator first: + +```json + "prebuild": { + "executor": "nx:run-commands", + "options": { + "command": "node libs/licensing/scripts/generate-public-key.mjs" + } + }, + "build": { + "executor": "@nx/js:tsc", + "dependsOn": ["prebuild"], + "outputs": ["{workspaceRoot}/dist/libs/licensing"], + "options": { + "outputPath": "dist/libs/licensing", + "main": "libs/licensing/src/index.ts", + "tsConfig": "libs/licensing/tsconfig.lib.json" + } + }, + "lint": { "executor": "@nx/eslint:lint" }, + "test": { + "executor": "@nx/vite:test", + "dependsOn": ["prebuild"], + "options": { "configFile": "libs/licensing/vite.config.mts" } + } +``` + +- [ ] **Step 5: Ignore the generated file** + +Append a single line to the root `.gitignore` after the `.angular` block: + +``` +# Generated license public key (produced by libs/licensing/scripts/generate-public-key.mjs) +libs/licensing/src/lib/license-public-key.generated.ts +``` + +- [ ] **Step 6: Wire the generator into root postinstall so consumer libraries can import from `@cacheplane/licensing` on a fresh clone** + +Edit the root `package.json` `scripts` block, adding: + +```json + "postinstall": "node libs/licensing/scripts/generate-public-key.mjs" +``` + +(If a `postinstall` already exists, chain with `&&`.) + +- [ ] **Step 7: Generate once locally and verify build** + +Run: +```bash +node libs/licensing/scripts/generate-public-key.mjs +npx nx build licensing +``` +Expected: generator logs `(source: dev-fixture)`; build succeeds; `dist/libs/licensing/lib/license-public-key.d.ts` exists. + +- [ ] **Step 8: Confirm prebuild chains on a clean generated file** + +Run: +```bash +rm libs/licensing/src/lib/license-public-key.generated.ts +npx nx build licensing --skip-nx-cache +``` +Expected: build still succeeds; Nx runs the prebuild target first and emits the generated file. + +- [ ] **Step 9: Commit** + +```bash +git add libs/licensing/fixtures libs/licensing/scripts libs/licensing/src/lib/license-public-key.ts libs/licensing/project.json .gitignore package.json +git commit -m "feat(licensing): embed ed25519 public key at build time" +``` + +--- + +## Task 8: `runLicenseCheck` orchestrator + public API + +**Files:** +- Create: `libs/licensing/src/lib/run-license-check.ts` +- Test: `libs/licensing/src/lib/run-license-check.spec.ts` +- Modify: `libs/licensing/src/index.ts` + +- [ ] **Step 1: Write the failing tests** + +`libs/licensing/src/lib/run-license-check.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { runLicenseCheck, __resetRunLicenseCheckStateForTests } from './run-license-check'; +import { __resetNagStateForTests } from './nag'; +import { generateKeyPair, signLicense, type DevKeyPair } from './testing/keypair'; +import type { LicenseClaims } from './license-token'; + +const BASE: LicenseClaims = { + sub: 'cus_abc', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 2_000_000_000, + seats: 1, +}; + +describe('runLicenseCheck', () => { + let kp: DevKeyPair; + let validToken: string; + let warn: ReturnType; + let fetchMock: ReturnType; + + beforeEach(async () => { + kp = await generateKeyPair(); + validToken = await signLicense(BASE, kp.privateKey); + warn = vi.fn(); + fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + __resetNagStateForTests(); + __resetRunLicenseCheckStateForTests(); + }); + afterEach(() => { + __resetNagStateForTests(); + __resetRunLicenseCheckStateForTests(); + }); + + it('does not warn with a valid token and still fires telemetry', async () => { + const status = await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: validToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + expect(status).toBe('licensed'); + expect(warn).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledOnce(); + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body.license_id).toBe('cus_abc'); + }); + + it('warns when token is missing', async () => { + const status = await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + expect(status).toBe('missing'); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('is idempotent per (package, token) pair', async () => { + await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: validToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: validToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + // Second call is a no-op: no extra warn (already guarded by nag dedupe anyway), + // and crucially no second telemetry POST. + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('re-runs when token changes (e.g., after key rotation in the host)', async () => { + const otherToken = await signLicense({ ...BASE, sub: 'cus_xyz' }, kp.privateKey); + await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: validToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: otherToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test licensing` +Expected: FAIL — `runLicenseCheck is not a function`. + +- [ ] **Step 3: Implement the orchestrator** + +`libs/licensing/src/lib/run-license-check.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { verifyLicense } from './verify-license'; +import { evaluateLicense, type LicenseStatus } from './evaluate-license'; +import { emitNag } from './nag'; +import { createTelemetryClient } from './telemetry'; + +export interface RunLicenseCheckOptions { + /** Fully-qualified host package name. */ + package: string; + /** Host package version (e.g., "1.0.0"). */ + version: string; + /** User-supplied license token, or undefined. */ + token?: string; + /** Ed25519 public key to verify against. */ + publicKey: Uint8Array; + /** Telemetry endpoint URL. */ + telemetryEndpoint: string; + /** Current time in epoch seconds. Defaults to now. Injected for testability. */ + nowSec?: number; + /** Hint that the environment is noncommercial (e.g. NODE_ENV !== 'production'). */ + isNoncommercial?: boolean; + /** Injected warn channel, defaults to console.warn. */ + warn?: (message: string) => void; + /** Injected fetch, defaults to globalThis.fetch. */ + fetch?: typeof fetch; +} + +const done = new Set(); + +export async function runLicenseCheck( + options: RunLicenseCheckOptions, +): Promise { + const key = `${options.package}|${options.token ?? ''}`; + if (done.has(key)) { + // Idempotent: re-running with identical inputs is a no-op. + return 'licensed'; + } + done.add(key); + + const nowSec = options.nowSec ?? Math.floor(Date.now() / 1000); + const verify = options.token + ? await verifyLicense(options.token, options.publicKey) + : undefined; + const evaluated = evaluateLicense(verify, { + nowSec, + isNoncommercial: options.isNoncommercial, + }); + + emitNag(evaluated, { package: options.package, warn: options.warn }); + + const telemetry = createTelemetryClient({ + endpoint: options.telemetryEndpoint, + fetch: options.fetch, + }); + // Fire-and-forget; do not await the host's init on it. + void telemetry.send({ + package: options.package, + version: options.version, + licenseId: evaluated.claims?.sub, + }); + + return evaluated.status; +} + +/** @internal testing hook only. */ +export function __resetRunLicenseCheckStateForTests(): void { + done.clear(); +} +``` + +- [ ] **Step 4: Update the public API barrel** + +Replace `libs/licensing/src/index.ts` with: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export type { LicenseClaims, LicenseTier } from './lib/license-token'; +export type { VerifyResult, VerifyReason } from './lib/verify-license'; +export { verifyLicense } from './lib/verify-license'; +export type { LicenseStatus, EvaluateResult, EvaluateOptions } from './lib/evaluate-license'; +export { evaluateLicense } from './lib/evaluate-license'; +export type { EmitNagOptions } from './lib/nag'; +export { emitNag } from './lib/nag'; +export type { + TelemetryEvent, + TelemetryClient, + CreateTelemetryClientOptions, +} from './lib/telemetry'; +export { createTelemetryClient } from './lib/telemetry'; +export type { RunLicenseCheckOptions } from './lib/run-license-check'; +export { runLicenseCheck } from './lib/run-license-check'; +export { LICENSE_PUBLIC_KEY } from './lib/license-public-key'; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test licensing` +Expected: PASS across all spec files. + +- [ ] **Step 6: Verify build picks up the new surface** + +Run: `npx nx build licensing` +Expected: `dist/libs/licensing/index.d.ts` exports `runLicenseCheck`, `verifyLicense`, `LICENSE_PUBLIC_KEY`, etc. + +- [ ] **Step 7: Commit** + +```bash +git add libs/licensing/src +git commit -m "feat(licensing): add runLicenseCheck orchestrator and public API" +``` + +--- + +## Task 9: README and license-check fixtures doc + +**Files:** +- Create: `libs/licensing/README.md` +- Create: `libs/licensing/src/lib/testing/fixtures.ts` + +- [ ] **Step 1: Write the fixture helper** + +`libs/licensing/src/lib/testing/fixtures.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Shared test fixtures: helper to produce signed tokens against a freshly +// generated keypair. Not exported from the package's public index. +import { signLicense, generateKeyPair, type DevKeyPair } from './keypair'; +import type { LicenseClaims } from '../license-token'; + +export interface FixturePack { + kp: DevKeyPair; + validToken: string; + expiredToken: string; + baseClaims: LicenseClaims; +} + +export async function buildFixturePack(): Promise { + const kp = await generateKeyPair(); + const baseClaims: LicenseClaims = { + sub: 'cus_fixture', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 2_000_000_000, + seats: 1, + }; + const validToken = await signLicense(baseClaims, kp.privateKey); + const expiredToken = await signLicense( + { ...baseClaims, exp: 1_700_100_000 }, + kp.privateKey, + ); + return { kp, validToken, expiredToken, baseClaims }; +} +``` + +- [ ] **Step 2: Write a short README** + +`libs/licensing/README.md`: + +```markdown +# @cacheplane/licensing + +Offline Ed25519 license verification + non-blocking telemetry for the Cacheplane +Angular framework libraries. + +## Status + +Private, pre-1.0. Consumed by `@cacheplane/angular`, `@cacheplane/render`, and +`@cacheplane/chat`. Not intended as a standalone import. + +## Behavior + +- `verifyLicense(token, publicKey)` — pure Ed25519 verification, no I/O. +- `evaluateLicense(result, { nowSec })` — returns one of + `licensed | grace | expired | missing | tampered | noncommercial`. +- `runLicenseCheck(options)` — runs verification, emits a single + `console.warn` with the `[cacheplane]` prefix when unlicensed, and fires a + non-blocking telemetry POST. +- **Never throws from init** — every failure mode is reported via warn, never + by throwing or blocking the host application's startup. +- **Opt out of telemetry** — set `CACHEPLANE_TELEMETRY=0` in the environment, or + `globalThis.CACHEPLANE_TELEMETRY = false`. +``` + +- [ ] **Step 3: Commit** + +```bash +git add libs/licensing/README.md libs/licensing/src/lib/testing/fixtures.ts +git commit -m "docs(licensing): add README and shared test fixtures" +``` + +--- + +## Task 10: Integrate license check into `@cacheplane/angular` + +**Files:** +- Modify: `libs/agent/src/lib/agent.provider.ts` +- Modify: `libs/agent/src/lib/agent.provider.spec.ts` +- Modify: `libs/agent/package.json` + +- [ ] **Step 1: Expose the test-only keypair helpers from `@cacheplane/licensing`** + +Append to `libs/licensing/src/index.ts`: + +```ts +// Testing subpath — not a stable public API. Safe to use from this monorepo's +// own tests; downstream consumers should not rely on these. +export { generateKeyPair, signLicense } from './lib/testing/keypair'; +export type { DevKeyPair } from './lib/testing/keypair'; +``` + +- [ ] **Step 2: Replace `libs/agent/src/lib/agent.provider.spec.ts` with the updated test suite** + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideAgent, AGENT_CONFIG } from './agent.provider'; +import { MockAgentTransport } from './transport/mock-stream.transport'; +import { signLicense, generateKeyPair } from '@cacheplane/licensing'; + +describe('provideAgent', () => { + it('provides AGENT_CONFIG token', () => { + TestBed.configureTestingModule({ + providers: [provideAgent({ apiUrl: 'https://api.example.com' })], + }); + const config = TestBed.inject(AGENT_CONFIG); + expect(config.apiUrl).toBe('https://api.example.com'); + }); + + it('provides custom transport via config', () => { + const transport = new MockAgentTransport(); + TestBed.configureTestingModule({ + providers: [provideAgent({ apiUrl: '', transport })], + }); + const config = TestBed.inject(AGENT_CONFIG); + expect(config.transport).toBe(transport); + }); + + it('runs a silent license check when a valid license is supplied', async () => { + const warn = vi.fn(); + globalThis.console.warn = warn; + const kp = await generateKeyPair(); + const token = await signLicense( + { + sub: 'cus_test', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 2_000_000_000, + seats: 1, + }, + kp.privateKey, + ); + TestBed.configureTestingModule({ + providers: [provideAgent({ apiUrl: '', license: token })], + }); + TestBed.inject(AGENT_CONFIG); + // Allow microtasks from the ed25519 verify + telemetry fire-and-forget. + await new Promise((r) => setTimeout(r, 0)); + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns when license is missing and env is production-like', async () => { + const warn = vi.fn(); + globalThis.console.warn = warn; + TestBed.configureTestingModule({ + providers: [ + provideAgent({ apiUrl: '', __licenseEnvHint: { isNoncommercial: false } }), + ], + }); + TestBed.inject(AGENT_CONFIG); + await new Promise((r) => setTimeout(r, 0)); + const calls = warn.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes('[cacheplane] @cacheplane/angular'))).toBe(true); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx nx test agent` +Expected: FAIL — `license` not a known property of `AgentConfig`. + +- [ ] **Step 4: Implement provider changes** + +Replace `libs/agent/src/lib/agent.provider.ts` with: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, Provider } from '@angular/core'; +import { runLicenseCheck, LICENSE_PUBLIC_KEY } from '@cacheplane/licensing'; +import { AgentTransport } from './agent.types'; + +const PACKAGE_NAME = '@cacheplane/angular'; +// Wired up by the release pipeline — imported lazily to avoid a hard build-time +// dependency on package.json. +declare const __CACHEPLANE_AGENT_VERSION__: string | undefined; +const PACKAGE_VERSION = + typeof __CACHEPLANE_AGENT_VERSION__ !== 'undefined' + ? __CACHEPLANE_AGENT_VERSION__ + : '0.0.0-dev'; +const TELEMETRY_ENDPOINT = + 'https://telemetry.cacheplane.dev/v1/ping'; + +/** + * Global configuration for agent instances. + * Properties set here serve as defaults that can be overridden per-call. + */ +export interface AgentConfig { + /** Base URL of the LangGraph Platform API (e.g., `'http://localhost:2024'`). */ + apiUrl?: string; + /** Custom transport implementation. Defaults to {@link FetchStreamTransport}. */ + transport?: AgentTransport; + /** Signed license token from cacheplane.dev. Optional; omitted in dev. */ + license?: string; + /** + * @internal + * Test-only env hint override. Not part of the stable API. + */ + __licenseEnvHint?: { isNoncommercial: boolean }; +} + +export const AGENT_CONFIG = new InjectionToken('AGENT_CONFIG'); + +function inferNoncommercial(): boolean { + if (typeof process !== 'undefined' && process.env) { + return process.env.NODE_ENV !== 'production'; + } + return false; +} + +/** + * Angular provider factory that registers global defaults for all + * agent instances in the application. + */ +export function provideAgent(config: AgentConfig): Provider { + // Fire-and-forget license check. Never blocks DI resolution. + void runLicenseCheck({ + package: PACKAGE_NAME, + version: PACKAGE_VERSION, + token: config.license, + publicKey: LICENSE_PUBLIC_KEY, + telemetryEndpoint: TELEMETRY_ENDPOINT, + isNoncommercial: + config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(), + }); + + return { provide: AGENT_CONFIG, useValue: config }; +} +``` + +- [ ] **Step 5: Add `@cacheplane/licensing` as a peer dependency** + +Edit `libs/agent/package.json` — add to the `peerDependencies` block: + +```json + "@cacheplane/licensing": "^0.0.1", +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npx nx test agent` +Expected: PASS — all 4 tests green. + +- [ ] **Step 7: Verify agent still builds** + +Run: `npx nx build agent` +Expected: build succeeds. + +- [ ] **Step 8: Commit** + +```bash +git add libs/agent/src/lib/agent.provider.ts libs/agent/src/lib/agent.provider.spec.ts libs/agent/package.json libs/licensing/src/index.ts +git commit -m "feat(agent): run license check at provider init" +``` + +--- + +## Task 11: Integrate license check into `@cacheplane/render` + +**Files:** +- Modify: `libs/render/src/lib/provide-render.ts` +- Modify: `libs/render/src/lib/render.types.ts` +- Create: `libs/render/src/lib/provide-render.spec.ts` +- Modify: `libs/render/package.json` + +- [ ] **Step 1: Write the failing test** + +`libs/render/src/lib/provide-render.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideRender, RENDER_CONFIG } from './provide-render'; + +describe('provideRender', () => { + beforeEach(() => { + globalThis.console.warn = vi.fn(); + }); + + it('provides RENDER_CONFIG token', () => { + TestBed.configureTestingModule({ providers: [provideRender({})] }); + const config = TestBed.inject(RENDER_CONFIG); + expect(config).toBeDefined(); + }); + + it('warns when license is missing in a production-like env', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRender({ __licenseEnvHint: { isNoncommercial: false } }), + ], + }); + TestBed.inject(RENDER_CONFIG); + await new Promise((r) => setTimeout(r, 0)); + const warn = globalThis.console.warn as ReturnType; + expect( + warn.mock.calls.some((c) => + String(c[0]).includes('[cacheplane] @cacheplane/render'), + ), + ).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Extend the `RenderConfig` type** + +In `libs/render/src/lib/render.types.ts`, add to the `RenderConfig` interface: + +```ts + /** Signed license token from cacheplane.dev. Optional; omitted in dev. */ + license?: string; + /** + * @internal + * Test-only env hint override. Not part of the stable API. + */ + __licenseEnvHint?: { isNoncommercial: boolean }; +``` + +- [ ] **Step 3: Implement provider changes** + +Replace `libs/render/src/lib/provide-render.ts` with: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import { runLicenseCheck, LICENSE_PUBLIC_KEY } from '@cacheplane/licensing'; +import type { RenderConfig } from './render.types'; + +const PACKAGE_NAME = '@cacheplane/render'; +declare const __CACHEPLANE_RENDER_VERSION__: string | undefined; +const PACKAGE_VERSION = + typeof __CACHEPLANE_RENDER_VERSION__ !== 'undefined' + ? __CACHEPLANE_RENDER_VERSION__ + : '0.0.0-dev'; +const TELEMETRY_ENDPOINT = 'https://telemetry.cacheplane.dev/v1/ping'; + +function inferNoncommercial(): boolean { + if (typeof process !== 'undefined' && process.env) { + return process.env.NODE_ENV !== 'production'; + } + return false; +} + +export const RENDER_CONFIG = new InjectionToken('RENDER_CONFIG'); + +export function provideRender(config: RenderConfig) { + void runLicenseCheck({ + package: PACKAGE_NAME, + version: PACKAGE_VERSION, + token: config.license, + publicKey: LICENSE_PUBLIC_KEY, + telemetryEndpoint: TELEMETRY_ENDPOINT, + isNoncommercial: + config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(), + }); + + return makeEnvironmentProviders([ + { provide: RENDER_CONFIG, useValue: config }, + ]); +} +``` + +- [ ] **Step 4: Add the peer dependency** + +Edit `libs/render/package.json` — add to the `peerDependencies` block: + +```json + "@cacheplane/licensing": "^0.0.1", +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test render` +Expected: PASS — new spec green, existing render tests still green. + +- [ ] **Step 6: Verify render still builds** + +Run: `npx nx build render` +Expected: build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add libs/render/src/lib/provide-render.ts libs/render/src/lib/provide-render.spec.ts libs/render/src/lib/render.types.ts libs/render/package.json +git commit -m "feat(render): run license check at provider init" +``` + +--- + +## Task 12: Integrate license check into `@cacheplane/chat` + +**Files:** +- Modify: `libs/chat/src/lib/provide-chat.ts` +- Create: `libs/chat/src/lib/provide-chat.spec.ts` +- Modify: `libs/chat/package.json` + +- [ ] **Step 1: Write the failing test** + +`libs/chat/src/lib/provide-chat.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideChat, CHAT_CONFIG } from './provide-chat'; + +describe('provideChat', () => { + beforeEach(() => { + globalThis.console.warn = vi.fn(); + }); + + it('provides CHAT_CONFIG token', () => { + TestBed.configureTestingModule({ providers: [provideChat({})] }); + const config = TestBed.inject(CHAT_CONFIG); + expect(config).toBeDefined(); + }); + + it('warns when license is missing in a production-like env', async () => { + TestBed.configureTestingModule({ + providers: [ + provideChat({ __licenseEnvHint: { isNoncommercial: false } }), + ], + }); + TestBed.inject(CHAT_CONFIG); + await new Promise((r) => setTimeout(r, 0)); + const warn = globalThis.console.warn as ReturnType; + expect( + warn.mock.calls.some((c) => + String(c[0]).includes('[cacheplane] @cacheplane/chat'), + ), + ).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Implement provider changes** + +Replace `libs/chat/src/lib/provide-chat.ts` with: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import { runLicenseCheck, LICENSE_PUBLIC_KEY } from '@cacheplane/licensing'; +import type { AngularRegistry } from '@cacheplane/render'; + +const PACKAGE_NAME = '@cacheplane/chat'; +declare const __CACHEPLANE_CHAT_VERSION__: string | undefined; +const PACKAGE_VERSION = + typeof __CACHEPLANE_CHAT_VERSION__ !== 'undefined' + ? __CACHEPLANE_CHAT_VERSION__ + : '0.0.0-dev'; +const TELEMETRY_ENDPOINT = 'https://telemetry.cacheplane.dev/v1/ping'; + +function inferNoncommercial(): boolean { + if (typeof process !== 'undefined' && process.env) { + return process.env.NODE_ENV !== 'production'; + } + return false; +} + +export interface ChatConfig { + /** Default render registry for generative UI components. */ + renderRegistry?: AngularRegistry; + /** Override the default AI avatar label (default: "A"). */ + avatarLabel?: string; + /** Override the default assistant display name (default: "Assistant"). */ + assistantName?: string; + /** Signed license token from cacheplane.dev. Optional; omitted in dev. */ + license?: string; + /** + * @internal + * Test-only env hint override. Not part of the stable API. + */ + __licenseEnvHint?: { isNoncommercial: boolean }; +} + +export const CHAT_CONFIG = new InjectionToken('CHAT_CONFIG'); + +export function provideChat(config: ChatConfig) { + void runLicenseCheck({ + package: PACKAGE_NAME, + version: PACKAGE_VERSION, + token: config.license, + publicKey: LICENSE_PUBLIC_KEY, + telemetryEndpoint: TELEMETRY_ENDPOINT, + isNoncommercial: + config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(), + }); + + return makeEnvironmentProviders([ + { provide: CHAT_CONFIG, useValue: config }, + ]); +} +``` + +- [ ] **Step 3: Add the peer dependency** + +Edit `libs/chat/package.json` — add to the `peerDependencies` block: + +```json + "@cacheplane/licensing": "^0.0.1", +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test chat` +Expected: PASS — new spec green, existing chat tests still green. + +- [ ] **Step 5: Verify chat still builds** + +Run: `npx nx build chat` +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/provide-chat.ts libs/chat/src/lib/provide-chat.spec.ts libs/chat/package.json +git commit -m "feat(chat): run license check at provider init" +``` + +--- + +## Task 13: Sanity check — full monorepo build, test, and lint + +**Files:** (no code changes; verification only) + +- [ ] **Step 1: Run full test suite across affected projects** + +Run: `npx nx run-many -t test -p licensing,agent,render,chat` +Expected: all tests pass. + +- [ ] **Step 2: Run lint across affected projects** + +Run: `npx nx run-many -t lint -p licensing,agent,render,chat` +Expected: no lint errors. + +- [ ] **Step 3: Run build across affected projects** + +Run: `npx nx run-many -t build -p licensing,agent,render,chat` +Expected: all four packages build; `dist/libs/licensing/index.d.ts` exports `runLicenseCheck`, and each of `dist/libs/{agent,render,chat}/` builds successfully with the new peer dep. + +- [ ] **Step 4: Smoke the nag path in a real dev build** + +Run: +```bash +node --input-type=module -e " +import { runLicenseCheck, LICENSE_PUBLIC_KEY } from './dist/libs/licensing/index.js'; +const warn = console.warn; +let captured = ''; +console.warn = (m) => { captured += m + '\n'; }; +await runLicenseCheck({ + package: '@cacheplane/test', + version: '1.0.0', + publicKey: LICENSE_PUBLIC_KEY, + telemetryEndpoint: 'https://telemetry.invalid/ping', + isNoncommercial: false, + nowSec: Math.floor(Date.now()/1000), +}); +await new Promise(r => setTimeout(r, 10)); +console.warn = warn; +console.log('---'); +console.log(captured); +" +``` +Expected: output contains `[cacheplane] @cacheplane/test:` and a pricing URL. Process exits 0 (telemetry failure is swallowed). + +- [ ] **Step 5: Commit the plan into the repo if not already done** + +No new source files should have changed in this task. If `git status` is clean, skip. + +--- + +## Scope Out (explicitly not in this plan) + +- **Minting service** — Stripe webhook handler + email delivery → Plan 2 (Minting & Telemetry Service). +- **Stripe Checkout wiring** — pricing page CTAs → Plan 3 (Stripe & Website Integration). +- **Public key rotation runbook** — documented in the v1 roadmap; lives in the minting plan once the rotation mechanism is real. +- **Legal texts** — `LICENSE-COMMERCIAL`, privacy policy updates → Plan 3. +- **Running the telemetry endpoint in production** → Plan 2. +- **Injecting the real production public key via CI** → already covered by the Release Infrastructure plan's "public key embedding at build time" task; this plan ships the mechanism (prebuild generator + env override), but the release pipeline is what sets the env in CI. + +--- + +## Self-Review Notes + +- **Spec coverage:** Every bullet under v1 roadmap §3 "Licensing & Billing / License key mechanism" is covered — Ed25519 signing (T3), customer id / tier / iat / exp / seats claims (T2), public key baked at build time (T7), offline verification (T3), nag mode (T5), non-blocking phone-home (T6), `{license_id, version, anon_instance_id}` payload (T6), init + daily behavior (init covered; daily POST deliberately deferred — the v1 spec says "init + daily" but a real 24h timer from inside a library is fragile; the minting service plan owns the daily heartbeat and we only ship init-time telemetry here, matching the spec's "Failure never blocks" contract). +- **Placeholder scan:** No TBDs or "implement later" markers; every code step has real code. +- **Type consistency:** `LicenseStatus` values are consistent across Tasks 4, 5, 8. `LicenseClaims` shape matches the parser in Task 2 and the signer in Task 3. `runLicenseCheck` signature matches the callers in Tasks 10–12. +- **Known trade-off:** `generateKeyPair` / `signLicense` / `DevKeyPair` are re-exported from the library's public index so the monorepo's own Angular specs can import them via the `@cacheplane/licensing` path alias. This leaks ~1KB of test utilities into the published bundle. The alternative — a `@cacheplane/licensing/testing` subpath with explicit `exports` wiring — is deferred because `@cacheplane/licensing` is a v1 transitive dependency, not one of the three documented v1 public packages, and the documented-as-internal helpers pay their weight by keeping the Angular-provider specs terse. Revisit when the library gets its own stabilization pass. From 3c825f1aa2cee77ccfba368b7501b78b571e7ed8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 07:45:06 -0700 Subject: [PATCH 02/51] feat(licensing): scaffold @cacheplane/licensing library Co-Authored-By: Claude Sonnet 4.6 --- libs/licensing/package.json | 13 +++++++++++++ libs/licensing/project.json | 23 +++++++++++++++++++++++ libs/licensing/src/index.ts | 3 +++ libs/licensing/tsconfig.json | 5 +++++ libs/licensing/tsconfig.lib.json | 6 ++++++ libs/licensing/vite.config.mts | 12 ++++++++++++ package-lock.json | 10 ++++++++++ package.json | 1 + tsconfig.base.json | 1 + 9 files changed, 74 insertions(+) create mode 100644 libs/licensing/package.json create mode 100644 libs/licensing/project.json create mode 100644 libs/licensing/src/index.ts create mode 100644 libs/licensing/tsconfig.json create mode 100644 libs/licensing/tsconfig.lib.json create mode 100644 libs/licensing/vite.config.mts diff --git a/libs/licensing/package.json b/libs/licensing/package.json new file mode 100644 index 000000000..3002908e8 --- /dev/null +++ b/libs/licensing/package.json @@ -0,0 +1,13 @@ +{ + "name": "@cacheplane/licensing", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false, + "publishConfig": { + "access": "public", + "provenance": true + }, + "peerDependencies": { + "@noble/ed25519": "^2.2.3" + } +} diff --git a/libs/licensing/project.json b/libs/licensing/project.json new file mode 100644 index 000000000..d99d0353c --- /dev/null +++ b/libs/licensing/project.json @@ -0,0 +1,23 @@ +{ + "name": "licensing", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/licensing/src", + "projectType": "library", + "tags": ["scope:shared", "type:lib"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/libs/licensing"], + "options": { + "outputPath": "dist/libs/licensing", + "main": "libs/licensing/src/index.ts", + "tsConfig": "libs/licensing/tsconfig.lib.json" + } + }, + "lint": { "executor": "@nx/eslint:lint" }, + "test": { + "executor": "@nx/vite:test", + "options": { "configFile": "libs/licensing/vite.config.mts" } + } + } +} diff --git a/libs/licensing/src/index.ts b/libs/licensing/src/index.ts new file mode 100644 index 000000000..25b32767c --- /dev/null +++ b/libs/licensing/src/index.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Public API — filled in by later tasks. +export {}; diff --git a/libs/licensing/tsconfig.json b/libs/licensing/tsconfig.json new file mode 100644 index 000000000..cf0cba0d6 --- /dev/null +++ b/libs/licensing/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/libs/licensing/tsconfig.lib.json b/libs/licensing/tsconfig.lib.json new file mode 100644 index 000000000..e6d236a47 --- /dev/null +++ b/libs/licensing/tsconfig.lib.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/lib/testing/**"] +} diff --git a/libs/licensing/vite.config.mts b/libs/licensing/vite.config.mts new file mode 100644 index 000000000..971c722be --- /dev/null +++ b/libs/licensing/vite.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + environment: 'node', + globals: true, + include: ['src/**/*.spec.ts'], + passWithNoTests: true, + }, +}); diff --git a/package-lock.json b/package-lock.json index d73b0196f..dd7a8c22f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1", + "@noble/ed25519": "^2.3.0", "framer-motion": "^12.38.0", "next": "~16.1.6", "next-mdx-remote": "^6.0.0", @@ -11228,6 +11229,15 @@ "webpack": "^5.54.0" } }, + "node_modules/@noble/ed25519": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz", + "integrity": "sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", diff --git a/package.json b/package.json index 9b031eec9..df196b59e 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1", + "@noble/ed25519": "^2.3.0", "framer-motion": "^12.38.0", "next": "~16.1.6", "next-mdx-remote": "^6.0.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index a27b1e954..43114cc8f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,7 @@ "@cacheplane/chat": ["libs/chat/src/public-api.ts"], "@cacheplane/partial-json": ["libs/partial-json/src/index.ts"], "@cacheplane/a2ui": ["libs/a2ui/src/index.ts"], + "@cacheplane/licensing": ["libs/licensing/src/index.ts"], "@cacheplane/example-layouts": ["libs/example-layouts/src/public-api.ts"] }, "skipLibCheck": true, From 17ae35f8e8065d919f95ec3fbee37c2f1d3a6ed2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 07:47:41 -0700 Subject: [PATCH 03/51] feat(licensing): add license token schema and parser Co-Authored-By: Claude Sonnet 4.6 --- libs/licensing/src/lib/license-token.spec.ts | 57 ++++++++++++++ libs/licensing/src/lib/license-token.ts | 79 ++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 libs/licensing/src/lib/license-token.spec.ts create mode 100644 libs/licensing/src/lib/license-token.ts diff --git a/libs/licensing/src/lib/license-token.spec.ts b/libs/licensing/src/lib/license-token.spec.ts new file mode 100644 index 000000000..38fbd5e57 --- /dev/null +++ b/libs/licensing/src/lib/license-token.spec.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { parseLicenseToken } from './license-token'; + +const CLAIMS = { + sub: 'cus_123', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 1_800_000_000, + seats: 5, +}; + +function b64url(bytes: Uint8Array | string): string { + const input = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes; + // Node's Buffer is available in vitest `environment: 'node'`. + return Buffer.from(input).toString('base64url'); +} + +describe('parseLicenseToken', () => { + it('splits a valid token into claims + signature bytes', () => { + const payloadJson = JSON.stringify(CLAIMS); + const signatureBytes = new Uint8Array(64).fill(7); + const token = `${b64url(payloadJson)}.${b64url(signatureBytes)}`; + + const result = parseLicenseToken(token); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok'); + expect(result.claims).toEqual(CLAIMS); + expect(result.signature).toEqual(signatureBytes); + expect(result.signedMessage).toEqual(new TextEncoder().encode(payloadJson)); + }); + + it('rejects a token with wrong number of segments', () => { + const result = parseLicenseToken('only-one-segment'); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('malformed'); + }); + + it('rejects a token with non-JSON payload', () => { + const token = `${b64url('not-json')}.${b64url(new Uint8Array(64))}`; + const result = parseLicenseToken(token); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('malformed'); + }); + + it('rejects a token missing required claims', () => { + const payload = JSON.stringify({ sub: 'cus_123' }); + const token = `${b64url(payload)}.${b64url(new Uint8Array(64))}`; + const result = parseLicenseToken(token); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('malformed'); + }); +}); diff --git a/libs/licensing/src/lib/license-token.ts b/libs/licensing/src/lib/license-token.ts new file mode 100644 index 000000000..25f5949aa --- /dev/null +++ b/libs/licensing/src/lib/license-token.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +/** The tier a license grants. */ +export type LicenseTier = 'developer-seat' | 'app-deployment' | 'enterprise'; + +/** Claims carried inside a signed license token. */ +export interface LicenseClaims { + /** Customer id (Stripe customer). */ + sub: string; + /** Tier the license grants. */ + tier: LicenseTier; + /** Issued-at, epoch seconds. */ + iat: number; + /** Expires-at, epoch seconds. */ + exp: number; + /** Seat count (>=1). */ + seats: number; +} + +export type ParseLicenseTokenResult = + | { + ok: true; + claims: LicenseClaims; + /** Raw bytes that were signed (UTF-8 of the payload segment). */ + signedMessage: Uint8Array; + signature: Uint8Array; + } + | { ok: false; reason: 'malformed' }; + +function base64UrlToBytes(s: string): Uint8Array | null { + try { + // base64url -> base64 + const pad = '='.repeat((4 - (s.length % 4)) % 4); + const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/'); + return Uint8Array.from(Buffer.from(b64, 'base64')); + } catch { + return null; + } +} + +function isLicenseClaims(value: unknown): value is LicenseClaims { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.sub === 'string' && + (v.tier === 'developer-seat' || + v.tier === 'app-deployment' || + v.tier === 'enterprise') && + typeof v.iat === 'number' && + typeof v.exp === 'number' && + typeof v.seats === 'number' && + v.seats >= 1 + ); +} + +/** + * Parse a compact license token of the form `.`. + * Does NOT verify the signature — see {@link verifyLicense}. + */ +export function parseLicenseToken(token: string): ParseLicenseTokenResult { + const parts = token.split('.'); + if (parts.length !== 2) return { ok: false, reason: 'malformed' }; + const [payloadSeg, signatureSeg] = parts; + + const payloadBytes = base64UrlToBytes(payloadSeg); + const signature = base64UrlToBytes(signatureSeg); + if (!payloadBytes || !signature) return { ok: false, reason: 'malformed' }; + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(payloadBytes)); + } catch { + return { ok: false, reason: 'malformed' }; + } + + if (!isLicenseClaims(parsed)) return { ok: false, reason: 'malformed' }; + + return { ok: true, claims: parsed, signedMessage: payloadBytes, signature }; +} From 5f0464d2895239b77719e0e63448d2f6429e39c6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 07:49:45 -0700 Subject: [PATCH 04/51] feat(licensing): add offline ed25519 license verification --- libs/licensing/src/lib/testing/keypair.ts | 33 ++++++++++ libs/licensing/src/lib/verify-license.spec.ts | 60 +++++++++++++++++++ libs/licensing/src/lib/verify-license.ts | 32 ++++++++++ 3 files changed, 125 insertions(+) create mode 100644 libs/licensing/src/lib/testing/keypair.ts create mode 100644 libs/licensing/src/lib/verify-license.spec.ts create mode 100644 libs/licensing/src/lib/verify-license.ts diff --git a/libs/licensing/src/lib/testing/keypair.ts b/libs/licensing/src/lib/testing/keypair.ts new file mode 100644 index 000000000..b2a023ada --- /dev/null +++ b/libs/licensing/src/lib/testing/keypair.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// TEST-ONLY utility: do not export from the package's public index. +import * as ed from '@noble/ed25519'; +import type { LicenseClaims } from '../license-token'; + +export interface DevKeyPair { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +export async function generateKeyPair(): Promise { + const privateKey = ed.utils.randomPrivateKey(); + const publicKey = await ed.getPublicKeyAsync(privateKey); + return { publicKey, privateKey }; +} + +function b64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64url'); +} + +/** + * Sign claims with the given private key and return a compact token + * `.`. Used by tests and by the + * dev-fixture generator; NOT used at runtime by the package. + */ +export async function signLicense( + claims: LicenseClaims, + privateKey: Uint8Array, +): Promise { + const payload = new TextEncoder().encode(JSON.stringify(claims)); + const sig = await ed.signAsync(payload, privateKey); + return `${b64url(payload)}.${b64url(sig)}`; +} diff --git a/libs/licensing/src/lib/verify-license.spec.ts b/libs/licensing/src/lib/verify-license.spec.ts new file mode 100644 index 000000000..c1f8e4e83 --- /dev/null +++ b/libs/licensing/src/lib/verify-license.spec.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { beforeAll, describe, it, expect } from 'vitest'; +import { verifyLicense } from './verify-license'; +import { generateKeyPair, signLicense, type DevKeyPair } from './testing/keypair'; +import type { LicenseClaims } from './license-token'; + +const BASE_CLAIMS: LicenseClaims = { + sub: 'cus_123', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 1_800_000_000, + seats: 5, +}; + +describe('verifyLicense', () => { + let kp: DevKeyPair; + let otherKp: DevKeyPair; + let validToken: string; + + beforeAll(async () => { + kp = await generateKeyPair(); + otherKp = await generateKeyPair(); + validToken = await signLicense(BASE_CLAIMS, kp.privateKey); + }); + + it('accepts a valid token signed with the matching key', async () => { + const result = await verifyLicense(validToken, kp.publicKey); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok'); + expect(result.claims).toEqual(BASE_CLAIMS); + }); + + it('rejects a token signed with a different key as tampered', async () => { + const badToken = await signLicense(BASE_CLAIMS, otherKp.privateKey); + const result = await verifyLicense(badToken, kp.publicKey); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('tampered'); + }); + + it('rejects a token whose payload has been mutated as tampered', async () => { + const [payload, sig] = validToken.split('.'); + // Flip one byte of the payload to invalidate the signature while + // keeping the shape valid. + const mutated = Buffer.from(payload + 'A', 'base64url'); + const tamperedToken = `${Buffer.from(mutated).toString('base64url')}.${sig}`; + const result = await verifyLicense(tamperedToken, kp.publicKey); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + // Could either fail as malformed (JSON parse) or tampered (sig check). + expect(['malformed', 'tampered']).toContain(result.reason); + }); + + it('rejects a malformed token', async () => { + const result = await verifyLicense('not-a-token', kp.publicKey); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected err'); + expect(result.reason).toBe('malformed'); + }); +}); diff --git a/libs/licensing/src/lib/verify-license.ts b/libs/licensing/src/lib/verify-license.ts new file mode 100644 index 000000000..fd1cd02ce --- /dev/null +++ b/libs/licensing/src/lib/verify-license.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import * as ed from '@noble/ed25519'; +import { parseLicenseToken, type LicenseClaims } from './license-token'; + +export type VerifyReason = 'malformed' | 'tampered'; + +export type VerifyResult = + | { ok: true; claims: LicenseClaims } + | { ok: false; reason: VerifyReason }; + +/** + * Offline-verify a license token against a raw Ed25519 public key. + * No network calls, no time-based checks — see {@link evaluateLicense} + * for grace-period / expiry logic. + */ +export async function verifyLicense( + token: string, + publicKey: Uint8Array, +): Promise { + const parsed = parseLicenseToken(token); + if (!parsed.ok) return { ok: false, reason: 'malformed' }; + + let valid = false; + try { + valid = await ed.verifyAsync(parsed.signature, parsed.signedMessage, publicKey); + } catch { + valid = false; + } + + if (!valid) return { ok: false, reason: 'tampered' }; + return { ok: true, claims: parsed.claims }; +} From 952d0671ac6dbf0d29e103cdbd9e441bfc8099f4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 07:52:05 -0700 Subject: [PATCH 05/51] feat(licensing): add license status evaluation with grace window Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/evaluate-license.spec.ts | 73 +++++++++++++++++++ libs/licensing/src/lib/evaluate-license.ts | 48 ++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 libs/licensing/src/lib/evaluate-license.spec.ts create mode 100644 libs/licensing/src/lib/evaluate-license.ts diff --git a/libs/licensing/src/lib/evaluate-license.spec.ts b/libs/licensing/src/lib/evaluate-license.spec.ts new file mode 100644 index 000000000..056fb88a6 --- /dev/null +++ b/libs/licensing/src/lib/evaluate-license.spec.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { evaluateLicense } from './evaluate-license'; +import type { LicenseClaims } from './license-token'; + +const CLAIMS: LicenseClaims = { + sub: 'cus_123', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 2_000_000_000, // far future + seats: 5, +}; + +const DAY = 86_400; + +describe('evaluateLicense', () => { + it('returns licensed when verify ok and not expired', () => { + const result = evaluateLicense( + { ok: true, claims: CLAIMS }, + { nowSec: 1_900_000_000 }, + ); + expect(result.status).toBe('licensed'); + expect(result.claims).toEqual(CLAIMS); + }); + + it('returns grace within 14 days after expiry', () => { + const expired = { ...CLAIMS, exp: 1_900_000_000 }; + const result = evaluateLicense( + { ok: true, claims: expired }, + { nowSec: 1_900_000_000 + 10 * DAY }, + ); + expect(result.status).toBe('grace'); + expect(result.claims).toEqual(expired); + }); + + it('returns expired past the 14 day grace window', () => { + const expired = { ...CLAIMS, exp: 1_900_000_000 }; + const result = evaluateLicense( + { ok: true, claims: expired }, + { nowSec: 1_900_000_000 + 15 * DAY }, + ); + expect(result.status).toBe('expired'); + }); + + it('returns tampered when verify failed with bad signature', () => { + const result = evaluateLicense( + { ok: false, reason: 'tampered' }, + { nowSec: 1_900_000_000 }, + ); + expect(result.status).toBe('tampered'); + }); + + it('returns missing when no token was supplied', () => { + const result = evaluateLicense(undefined, { nowSec: 1_900_000_000 }); + expect(result.status).toBe('missing'); + }); + + it('returns noncommercial when no token and dev env is hinted', () => { + const result = evaluateLicense(undefined, { + nowSec: 1_900_000_000, + isNoncommercial: true, + }); + expect(result.status).toBe('noncommercial'); + }); + + it('still returns licensed in noncommercial env when a valid token is present', () => { + const result = evaluateLicense( + { ok: true, claims: CLAIMS }, + { nowSec: 1_900_000_000, isNoncommercial: true }, + ); + expect(result.status).toBe('licensed'); + }); +}); diff --git a/libs/licensing/src/lib/evaluate-license.ts b/libs/licensing/src/lib/evaluate-license.ts new file mode 100644 index 000000000..cb3cf63ea --- /dev/null +++ b/libs/licensing/src/lib/evaluate-license.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { LicenseClaims } from './license-token'; +import type { VerifyResult } from './verify-license'; + +export type LicenseStatus = + | 'licensed' // valid signed token, not expired + | 'grace' // valid signed token, expired but within grace window + | 'expired' // valid signed token, past grace window + | 'missing' // no token and not noncommercial + | 'tampered' // token present, failed signature or malformed + | 'noncommercial'; // no token, env looks noncommercial + +export interface EvaluateOptions { + /** Current time in epoch seconds. Injected for testability. */ + nowSec: number; + /** Grace window in seconds after `exp`. Defaults to 14 days. */ + graceSec?: number; + /** If true, missing token resolves to `noncommercial` instead of `missing`. */ + isNoncommercial?: boolean; +} + +export interface EvaluateResult { + status: LicenseStatus; + /** Populated when the token was valid (licensed / grace / expired). */ + claims?: LicenseClaims; +} + +const FOURTEEN_DAYS_SEC = 14 * 24 * 60 * 60; + +export function evaluateLicense( + verifyResult: VerifyResult | undefined, + options: EvaluateOptions, +): EvaluateResult { + const grace = options.graceSec ?? FOURTEEN_DAYS_SEC; + + if (!verifyResult) { + return { status: options.isNoncommercial ? 'noncommercial' : 'missing' }; + } + + if (!verifyResult.ok) { + return { status: 'tampered' }; + } + + const { claims } = verifyResult; + if (options.nowSec <= claims.exp) return { status: 'licensed', claims }; + if (options.nowSec <= claims.exp + grace) return { status: 'grace', claims }; + return { status: 'expired', claims }; +} From 192cdc73ebe35a912f45b2dbd77657f2994aeb41 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 07:53:42 -0700 Subject: [PATCH 06/51] feat(licensing): add nag UX with per-package dedupe --- libs/licensing/src/lib/nag.spec.ts | 56 ++++++++++++++++++++++++++++++ libs/licensing/src/lib/nag.ts | 43 +++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 libs/licensing/src/lib/nag.spec.ts create mode 100644 libs/licensing/src/lib/nag.ts diff --git a/libs/licensing/src/lib/nag.spec.ts b/libs/licensing/src/lib/nag.spec.ts new file mode 100644 index 000000000..e76b179cc --- /dev/null +++ b/libs/licensing/src/lib/nag.spec.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { emitNag, __resetNagStateForTests } from './nag'; + +describe('emitNag', () => { + const warn = vi.fn(); + + beforeEach(() => { + warn.mockClear(); + __resetNagStateForTests(); + }); + afterEach(() => { + __resetNagStateForTests(); + }); + + it('is silent when status is licensed', () => { + emitNag({ status: 'licensed' }, { package: '@cacheplane/angular', warn }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('is silent when status is noncommercial', () => { + emitNag({ status: 'noncommercial' }, { package: '@cacheplane/angular', warn }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns with a stable prefix when status is missing', () => { + emitNag({ status: 'missing' }, { package: '@cacheplane/angular', warn }); + expect(warn).toHaveBeenCalledTimes(1); + const message = warn.mock.calls[0][0] as string; + expect(message).toContain('[cacheplane]'); + expect(message).toContain('@cacheplane/angular'); + expect(message).toContain('cacheplane.dev/pricing'); + }); + + it('warns differently for grace / expired / tampered', () => { + emitNag({ status: 'grace' }, { package: '@cacheplane/angular', warn }); + emitNag({ status: 'expired' }, { package: '@cacheplane/render', warn }); + emitNag({ status: 'tampered' }, { package: '@cacheplane/chat', warn }); + expect(warn).toHaveBeenCalledTimes(3); + expect(warn.mock.calls[0][0]).toMatch(/grace/i); + expect(warn.mock.calls[1][0]).toMatch(/expired/i); + expect(warn.mock.calls[2][0]).toMatch(/tampered|invalid/i); + }); + + it('dedupes repeated calls for the same package + status', () => { + emitNag({ status: 'missing' }, { package: '@cacheplane/angular', warn }); + emitNag({ status: 'missing' }, { package: '@cacheplane/angular', warn }); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('does not dedupe across different packages', () => { + emitNag({ status: 'missing' }, { package: '@cacheplane/angular', warn }); + emitNag({ status: 'missing' }, { package: '@cacheplane/render', warn }); + expect(warn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/libs/licensing/src/lib/nag.ts b/libs/licensing/src/lib/nag.ts new file mode 100644 index 000000000..0be607a01 --- /dev/null +++ b/libs/licensing/src/lib/nag.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { EvaluateResult } from './evaluate-license'; + +export interface EmitNagOptions { + /** Fully-qualified npm package name, e.g. "@cacheplane/angular". */ + package: string; + /** Injected warn channel; defaults to `console.warn`. */ + warn?: (message: string) => void; +} + +const seen = new Set(); + +const MESSAGES: Record = { + missing: + 'no license key detected. The library will keep running, but please get a license at https://cacheplane.dev/pricing', + grace: + 'license is expired and within the 14-day grace window. Renew at https://cacheplane.dev/pricing', + expired: + 'license is expired. The library will keep running, but please renew at https://cacheplane.dev/pricing', + tampered: + 'license signature is invalid or malformed. Download a fresh key from https://cacheplane.dev/pricing', +}; + +export function emitNag( + result: Pick, + options: EmitNagOptions, +): void { + const warn = options.warn ?? ((m: string) => console.warn(m)); + const { status } = result; + if (status === 'licensed' || status === 'noncommercial') return; + + const key = `${options.package}|${status}`; + if (seen.has(key)) return; + seen.add(key); + + const body = MESSAGES[status] ?? 'license check failed.'; + warn(`[cacheplane] ${options.package}: ${body}`); +} + +/** @internal testing hook only. */ +export function __resetNagStateForTests(): void { + seen.clear(); +} From 5e3787c2babbb1352123fa1f2ceb3ad07fa1a8c7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 07:55:31 -0700 Subject: [PATCH 07/51] feat(licensing): add non-blocking telemetry client with opt-out --- libs/licensing/src/lib/telemetry.spec.ts | 91 ++++++++++++++++++++++++ libs/licensing/src/lib/telemetry.ts | 71 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 libs/licensing/src/lib/telemetry.spec.ts create mode 100644 libs/licensing/src/lib/telemetry.ts diff --git a/libs/licensing/src/lib/telemetry.spec.ts b/libs/licensing/src/lib/telemetry.spec.ts new file mode 100644 index 000000000..8b7e9bc94 --- /dev/null +++ b/libs/licensing/src/lib/telemetry.spec.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { createTelemetryClient } from './telemetry'; + +describe('createTelemetryClient', () => { + const origEnv = { ...process.env }; + const origGlobal = (globalThis as Record).CACHEPLANE_TELEMETRY; + + beforeEach(() => { + process.env = { ...origEnv }; + delete process.env.CACHEPLANE_TELEMETRY; + delete (globalThis as Record).CACHEPLANE_TELEMETRY; + }); + + afterEach(() => { + process.env = origEnv; + (globalThis as Record).CACHEPLANE_TELEMETRY = origGlobal; + }); + + it('posts a payload to the endpoint with a generated anon_instance_id', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + + await client.send({ + package: '@cacheplane/angular', + version: '1.0.0', + licenseId: 'cus_123', + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://telemetry.example.com/v1/ping'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body.package).toBe('@cacheplane/angular'); + expect(body.version).toBe('1.0.0'); + expect(body.license_id).toBe('cus_123'); + expect(typeof body.anon_instance_id).toBe('string'); + expect(body.anon_instance_id.length).toBeGreaterThan(0); + }); + + it('reuses the same anon_instance_id across calls from the same client', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + await client.send({ package: '@cacheplane/angular', version: '1.0.0' }); + await client.send({ package: '@cacheplane/angular', version: '1.0.0' }); + + const id1 = JSON.parse(fetchMock.mock.calls[0][1].body as string).anon_instance_id; + const id2 = JSON.parse(fetchMock.mock.calls[1][1].body as string).anon_instance_id; + expect(id1).toBe(id2); + }); + + it('is a no-op when CACHEPLANE_TELEMETRY=0 env is set', async () => { + process.env.CACHEPLANE_TELEMETRY = '0'; + const fetchMock = vi.fn(); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + await client.send({ package: '@cacheplane/angular', version: '1.0.0' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('is a no-op when globalThis.CACHEPLANE_TELEMETRY === false', async () => { + (globalThis as Record).CACHEPLANE_TELEMETRY = false; + const fetchMock = vi.fn(); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + await client.send({ package: '@cacheplane/angular', version: '1.0.0' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('never throws when fetch rejects', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('network down')); + const client = createTelemetryClient({ + endpoint: 'https://telemetry.example.com/v1/ping', + fetch: fetchMock, + }); + await expect( + client.send({ package: '@cacheplane/angular', version: '1.0.0' }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/libs/licensing/src/lib/telemetry.ts b/libs/licensing/src/lib/telemetry.ts new file mode 100644 index 000000000..ead2a9dd7 --- /dev/null +++ b/libs/licensing/src/lib/telemetry.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +export interface TelemetryEvent { + package: string; + version: string; + licenseId?: string; +} + +export interface TelemetryClient { + send(event: TelemetryEvent): Promise; +} + +export interface CreateTelemetryClientOptions { + endpoint: string; + /** Injected for testability. Defaults to the global `fetch`. */ + fetch?: typeof fetch; + /** Injected for testability. Defaults to `crypto.randomUUID()`. */ + generateInstanceId?: () => string; +} + +function isOptedOut(): boolean { + const envFlag = + typeof process !== 'undefined' && process.env + ? process.env.CACHEPLANE_TELEMETRY + : undefined; + if (envFlag === '0' || envFlag === 'false') return true; + const g = (globalThis as Record).CACHEPLANE_TELEMETRY; + if (g === false || g === 0 || g === '0') return true; + return false; +} + +function defaultInstanceId(): string { + // `crypto.randomUUID` is available in Node 19+, modern browsers, + // and all edge runtimes we target. + return crypto.randomUUID(); +} + +export function createTelemetryClient( + options: CreateTelemetryClientOptions, +): TelemetryClient { + const fetchImpl = options.fetch ?? globalThis.fetch; + const makeId = options.generateInstanceId ?? defaultInstanceId; + const anonInstanceId = makeId(); + + return { + async send(event: TelemetryEvent): Promise { + if (isOptedOut()) return; + if (!fetchImpl) return; + + const body = JSON.stringify({ + package: event.package, + version: event.version, + license_id: event.licenseId, + anon_instance_id: anonInstanceId, + ts: Math.floor(Date.now() / 1000), + }); + + try { + await fetchImpl(options.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + // `keepalive` helps in browser unload paths; harmless elsewhere. + keepalive: true, + }); + } catch { + // Never block the host app on telemetry failure. + } + }, + }; +} From b9aac38493ded13288d4f25f39f4b56f7aa0c450 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 07:58:17 -0700 Subject: [PATCH 08/51] feat(licensing): embed ed25519 public key at build time Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 ++ libs/licensing/fixtures/dev-public-key.hex | 1 + libs/licensing/project.json | 8 +++ .../licensing/scripts/generate-public-key.mjs | 51 +++++++++++++++++++ libs/licensing/src/lib/license-public-key.ts | 13 +++++ package.json | 1 + 6 files changed, 77 insertions(+) create mode 100644 libs/licensing/fixtures/dev-public-key.hex create mode 100644 libs/licensing/scripts/generate-public-key.mjs create mode 100644 libs/licensing/src/lib/license-public-key.ts diff --git a/.gitignore b/.gitignore index 0dec8d667..909ab9c86 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ __pycache__/ # Local LangGraph deployment deps deployments/*/deps/ + +# Generated license public key (produced by libs/licensing/scripts/generate-public-key.mjs) +libs/licensing/src/lib/license-public-key.generated.ts diff --git a/libs/licensing/fixtures/dev-public-key.hex b/libs/licensing/fixtures/dev-public-key.hex new file mode 100644 index 000000000..a907f2a4f --- /dev/null +++ b/libs/licensing/fixtures/dev-public-key.hex @@ -0,0 +1 @@ +793132582f3d39dcd46cc6fd010c6c4b10f1225132e7de71fbcb45788ea5afde \ No newline at end of file diff --git a/libs/licensing/project.json b/libs/licensing/project.json index d99d0353c..6fe6fa91b 100644 --- a/libs/licensing/project.json +++ b/libs/licensing/project.json @@ -5,8 +5,15 @@ "projectType": "library", "tags": ["scope:shared", "type:lib"], "targets": { + "prebuild": { + "executor": "nx:run-commands", + "options": { + "command": "node libs/licensing/scripts/generate-public-key.mjs" + } + }, "build": { "executor": "@nx/js:tsc", + "dependsOn": ["prebuild"], "outputs": ["{workspaceRoot}/dist/libs/licensing"], "options": { "outputPath": "dist/libs/licensing", @@ -17,6 +24,7 @@ "lint": { "executor": "@nx/eslint:lint" }, "test": { "executor": "@nx/vite:test", + "dependsOn": ["prebuild"], "options": { "configFile": "libs/licensing/vite.config.mts" } } } diff --git a/libs/licensing/scripts/generate-public-key.mjs b/libs/licensing/scripts/generate-public-key.mjs new file mode 100644 index 000000000..5b1b6ed34 --- /dev/null +++ b/libs/licensing/scripts/generate-public-key.mjs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Emits libs/licensing/src/lib/license-public-key.generated.ts with +// the Ed25519 public key to use at runtime. +// +// Priority: +// 1. env CACHEPLANE_LICENSE_PUBLIC_KEY (hex or base64) — used in release builds +// 2. libs/licensing/fixtures/dev-public-key.hex — used in local dev +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LIB_SRC = resolve(__dirname, '../src/lib'); +const OUT = resolve(LIB_SRC, 'license-public-key.generated.ts'); +const FIXTURE = resolve(__dirname, '../fixtures/dev-public-key.hex'); + +function parseKey(raw) { + const trimmed = raw.trim(); + if (/^[0-9a-fA-F]+$/.test(trimmed)) { + if (trimmed.length !== 64) { + throw new Error(`expected 32-byte hex (64 chars), got ${trimmed.length}`); + } + return Buffer.from(trimmed, 'hex'); + } + // Otherwise try base64 / base64url. + const b64 = trimmed.replace(/-/g, '+').replace(/_/g, '/'); + const buf = Buffer.from(b64, 'base64'); + if (buf.length !== 32) { + throw new Error(`expected 32-byte base64 key, got ${buf.length}`); + } + return buf; +} + +const source = + process.env.CACHEPLANE_LICENSE_PUBLIC_KEY + ? { raw: process.env.CACHEPLANE_LICENSE_PUBLIC_KEY, origin: 'env' } + : { raw: readFileSync(FIXTURE, 'utf8'), origin: 'dev-fixture' }; + +const keyBytes = parseKey(source.raw); +const hex = Buffer.from(keyBytes).toString('hex'); + +mkdirSync(LIB_SRC, { recursive: true }); +writeFileSync( + OUT, + `// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// AUTOGENERATED by libs/licensing/scripts/generate-public-key.mjs — do not edit. +// Source: ${source.origin} +export const LICENSE_PUBLIC_KEY_HEX = '${hex}' as const; +`, +); +console.log(`[licensing] wrote ${OUT} (source: ${source.origin})`); diff --git a/libs/licensing/src/lib/license-public-key.ts b/libs/licensing/src/lib/license-public-key.ts new file mode 100644 index 000000000..5f69302b2 --- /dev/null +++ b/libs/licensing/src/lib/license-public-key.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { LICENSE_PUBLIC_KEY_HEX } from './license-public-key.generated'; + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +/** Ed25519 public key baked into this build of `@cacheplane/licensing`. */ +export const LICENSE_PUBLIC_KEY: Uint8Array = hexToBytes(LICENSE_PUBLIC_KEY_HEX); diff --git a/package.json b/package.json index df196b59e..d44bc0191 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "license": "PolyForm-Noncommercial-1.0.0", "scripts": { + "postinstall": "node libs/licensing/scripts/generate-public-key.mjs", "generate-agent-context": "npx tsx --tsconfig apps/website/tsconfig.json apps/website/scripts/generate-agent-context.ts", "generate-api-docs": "npx tsx apps/website/scripts/generate-api-docs.ts", "generate-narrative-docs": "npx tsx apps/website/scripts/generate-narrative-docs.ts", From f8d816b326d2f11b58de73d88e0eda9c6f5ffc5a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 08:00:44 -0700 Subject: [PATCH 09/51] feat(licensing): add runLicenseCheck orchestrator and public API Co-Authored-By: Claude Sonnet 4.6 --- libs/licensing/src/index.ts | 18 ++- .../src/lib/run-license-check.spec.ts | 117 ++++++++++++++++++ libs/licensing/src/lib/run-license-check.ts | 68 ++++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 libs/licensing/src/lib/run-license-check.spec.ts create mode 100644 libs/licensing/src/lib/run-license-check.ts diff --git a/libs/licensing/src/index.ts b/libs/licensing/src/index.ts index 25b32767c..2d064b2fa 100644 --- a/libs/licensing/src/index.ts +++ b/libs/licensing/src/index.ts @@ -1,3 +1,17 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -// Public API — filled in by later tasks. -export {}; +export type { LicenseClaims, LicenseTier } from './lib/license-token'; +export type { VerifyResult, VerifyReason } from './lib/verify-license'; +export { verifyLicense } from './lib/verify-license'; +export type { LicenseStatus, EvaluateResult, EvaluateOptions } from './lib/evaluate-license'; +export { evaluateLicense } from './lib/evaluate-license'; +export type { EmitNagOptions } from './lib/nag'; +export { emitNag } from './lib/nag'; +export type { + TelemetryEvent, + TelemetryClient, + CreateTelemetryClientOptions, +} from './lib/telemetry'; +export { createTelemetryClient } from './lib/telemetry'; +export type { RunLicenseCheckOptions } from './lib/run-license-check'; +export { runLicenseCheck } from './lib/run-license-check'; +export { LICENSE_PUBLIC_KEY } from './lib/license-public-key'; diff --git a/libs/licensing/src/lib/run-license-check.spec.ts b/libs/licensing/src/lib/run-license-check.spec.ts new file mode 100644 index 000000000..6c6774c73 --- /dev/null +++ b/libs/licensing/src/lib/run-license-check.spec.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { runLicenseCheck, __resetRunLicenseCheckStateForTests } from './run-license-check'; +import { __resetNagStateForTests } from './nag'; +import { generateKeyPair, signLicense, type DevKeyPair } from './testing/keypair'; +import type { LicenseClaims } from './license-token'; + +const BASE: LicenseClaims = { + sub: 'cus_abc', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 2_000_000_000, + seats: 1, +}; + +describe('runLicenseCheck', () => { + let kp: DevKeyPair; + let validToken: string; + let warn: ReturnType; + let fetchMock: ReturnType; + + beforeEach(async () => { + kp = await generateKeyPair(); + validToken = await signLicense(BASE, kp.privateKey); + warn = vi.fn(); + fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + __resetNagStateForTests(); + __resetRunLicenseCheckStateForTests(); + }); + afterEach(() => { + __resetNagStateForTests(); + __resetRunLicenseCheckStateForTests(); + }); + + it('does not warn with a valid token and still fires telemetry', async () => { + const status = await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: validToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + expect(status).toBe('licensed'); + expect(warn).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledOnce(); + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body.license_id).toBe('cus_abc'); + }); + + it('warns when token is missing', async () => { + const status = await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + expect(status).toBe('missing'); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('is idempotent per (package, token) pair', async () => { + await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: validToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: validToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + // Second call is a no-op: no extra warn (already guarded by nag dedupe anyway), + // and crucially no second telemetry POST. + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('re-runs when token changes (e.g., after key rotation in the host)', async () => { + const otherToken = await signLicense({ ...BASE, sub: 'cus_xyz' }, kp.privateKey); + await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: validToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + await runLicenseCheck({ + package: '@cacheplane/angular', + version: '1.0.0', + token: otherToken, + publicKey: kp.publicKey, + nowSec: 1_900_000_000, + telemetryEndpoint: 'https://t.example.com/v1', + warn, + fetch: fetchMock, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/libs/licensing/src/lib/run-license-check.ts b/libs/licensing/src/lib/run-license-check.ts new file mode 100644 index 000000000..cd1e9d729 --- /dev/null +++ b/libs/licensing/src/lib/run-license-check.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { verifyLicense } from './verify-license'; +import { evaluateLicense, type LicenseStatus } from './evaluate-license'; +import { emitNag } from './nag'; +import { createTelemetryClient } from './telemetry'; + +export interface RunLicenseCheckOptions { + /** Fully-qualified host package name. */ + package: string; + /** Host package version (e.g., "1.0.0"). */ + version: string; + /** User-supplied license token, or undefined. */ + token?: string; + /** Ed25519 public key to verify against. */ + publicKey: Uint8Array; + /** Telemetry endpoint URL. */ + telemetryEndpoint: string; + /** Current time in epoch seconds. Defaults to now. Injected for testability. */ + nowSec?: number; + /** Hint that the environment is noncommercial (e.g. NODE_ENV !== 'production'). */ + isNoncommercial?: boolean; + /** Injected warn channel, defaults to console.warn. */ + warn?: (message: string) => void; + /** Injected fetch, defaults to globalThis.fetch. */ + fetch?: typeof fetch; +} + +const done = new Set(); + +export async function runLicenseCheck( + options: RunLicenseCheckOptions, +): Promise { + const key = `${options.package}|${options.token ?? ''}`; + if (done.has(key)) { + // Idempotent: re-running with identical inputs is a no-op. + return 'licensed'; + } + done.add(key); + + const nowSec = options.nowSec ?? Math.floor(Date.now() / 1000); + const verify = options.token + ? await verifyLicense(options.token, options.publicKey) + : undefined; + const evaluated = evaluateLicense(verify, { + nowSec, + isNoncommercial: options.isNoncommercial, + }); + + emitNag(evaluated, { package: options.package, warn: options.warn }); + + const telemetry = createTelemetryClient({ + endpoint: options.telemetryEndpoint, + fetch: options.fetch, + }); + // Fire-and-forget; do not await the host's init on it. + void telemetry.send({ + package: options.package, + version: options.version, + licenseId: evaluated.claims?.sub, + }); + + return evaluated.status; +} + +/** @internal testing hook only. */ +export function __resetRunLicenseCheckStateForTests(): void { + done.clear(); +} From 6cb64a6a5c6818df5119ae0e8ff0e2c1850489b1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 08:01:43 -0700 Subject: [PATCH 10/51] docs(licensing): add README and shared test fixtures --- libs/licensing/README.md | 22 ++++++++++++++++ libs/licensing/src/lib/testing/fixtures.ts | 29 ++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 libs/licensing/README.md create mode 100644 libs/licensing/src/lib/testing/fixtures.ts diff --git a/libs/licensing/README.md b/libs/licensing/README.md new file mode 100644 index 000000000..efee9f90c --- /dev/null +++ b/libs/licensing/README.md @@ -0,0 +1,22 @@ +# @cacheplane/licensing + +Offline Ed25519 license verification + non-blocking telemetry for the Cacheplane +Angular framework libraries. + +## Status + +Private, pre-1.0. Consumed by `@cacheplane/angular`, `@cacheplane/render`, and +`@cacheplane/chat`. Not intended as a standalone import. + +## Behavior + +- `verifyLicense(token, publicKey)` — pure Ed25519 verification, no I/O. +- `evaluateLicense(result, { nowSec })` — returns one of + `licensed | grace | expired | missing | tampered | noncommercial`. +- `runLicenseCheck(options)` — runs verification, emits a single + `console.warn` with the `[cacheplane]` prefix when unlicensed, and fires a + non-blocking telemetry POST. +- **Never throws from init** — every failure mode is reported via warn, never + by throwing or blocking the host application's startup. +- **Opt out of telemetry** — set `CACHEPLANE_TELEMETRY=0` in the environment, or + `globalThis.CACHEPLANE_TELEMETRY = false`. diff --git a/libs/licensing/src/lib/testing/fixtures.ts b/libs/licensing/src/lib/testing/fixtures.ts new file mode 100644 index 000000000..f00f8bd07 --- /dev/null +++ b/libs/licensing/src/lib/testing/fixtures.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Shared test fixtures: helper to produce signed tokens against a freshly +// generated keypair. Not exported from the package's public index. +import { signLicense, generateKeyPair, type DevKeyPair } from './keypair'; +import type { LicenseClaims } from '../license-token'; + +export interface FixturePack { + kp: DevKeyPair; + validToken: string; + expiredToken: string; + baseClaims: LicenseClaims; +} + +export async function buildFixturePack(): Promise { + const kp = await generateKeyPair(); + const baseClaims: LicenseClaims = { + sub: 'cus_fixture', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 2_000_000_000, + seats: 1, + }; + const validToken = await signLicense(baseClaims, kp.privateKey); + const expiredToken = await signLicense( + { ...baseClaims, exp: 1_700_100_000 }, + kp.privateKey, + ); + return { kp, validToken, expiredToken, baseClaims }; +} From c407e7ed74bdf00cde2f1a16540c3fd1a9eec54e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 09:20:11 -0700 Subject: [PATCH 11/51] docs: revise T10-T12 integration tasks with __licensePublicKey hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original T10 test design had a fresh-keypair/LICENSE_PUBLIC_KEY mismatch: the silent-verify test signed with an ephemeral pair but the provider verified against the compile-time embedded public key, making the test unprovable without either committing a private key fixture (security smell) or stripping the testing exclude from the published lib (ships test helpers to consumers). Revise T10-T12 to add an @internal __licensePublicKey?: Uint8Array override on each config interface, defaulting to LICENSE_PUBLIC_KEY in production. Tests pass the ephemeral pair's public key via this hook, so nothing in the repo ever needs to sign with or store a private key. Also add explicit guardrails to each task forbidding: committing private-key fixtures, mutating testing/keypair.ts, removing tsconfig exclusions, and unilaterally patching test-setup.ts or tsconfig.base.json when jsdom/Nx environment issues appear — failures should escalate to the controller. Co-Authored-By: Claude Opus 4 --- ...2026-04-19-license-verification-library.md | 115 +++++++++++++++--- 1 file changed, 101 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-04-19-license-verification-library.md b/docs/superpowers/plans/2026-04-19-license-verification-library.md index 2ab7c6586..da6c1a506 100644 --- a/docs/superpowers/plans/2026-04-19-license-verification-library.md +++ b/docs/superpowers/plans/2026-04-19-license-verification-library.md @@ -1568,11 +1568,22 @@ git commit -m "docs(licensing): add README and shared test fixtures" ## Task 10: Integrate license check into `@cacheplane/angular` **Files:** +- Modify: `libs/licensing/src/index.ts` - Modify: `libs/agent/src/lib/agent.provider.ts` - Modify: `libs/agent/src/lib/agent.provider.spec.ts` - Modify: `libs/agent/package.json` -- [ ] **Step 1: Expose the test-only keypair helpers from `@cacheplane/licensing`** +**Guardrails (read before starting):** + +- **Do NOT commit any private key to the repo.** The only fixture under `libs/licensing/fixtures/` is `dev-public-key.hex`. There is no `dev-private-key.hex`. Do not create one. Every test must mint its keypair at runtime via `generateKeyPair()` from `@cacheplane/licensing`. +- **Do NOT modify `libs/licensing/src/lib/testing/keypair.ts`** in this task. `generateKeyPair()` must stay non-deterministic. +- **Do NOT change `libs/licensing/tsconfig.lib.json`.** The `testing/**` exclude stays; testing helpers must not ship in the published `dist/`. +- **Do NOT change `libs/licensing/project.json`** or the prebuild wiring. +- **Architectural key:** the provider hardcodes `LICENSE_PUBLIC_KEY` in production, but `AgentConfig` gains an `@internal __licensePublicKey?: Uint8Array` escape hatch so tests can verify against an ephemeral pair without touching the compile-time constant. Mirror the shape of the existing `__licenseEnvHint` hook. +- **If tests fail in jsdom** with an `ed25519`/`SubtleCrypto` cross-realm `ArrayBuffer` error, first report the failure back to the controller (do not silently monkeypatch `sha512Async` in `test-setup.ts`). We'll decide together whether the patch is warranted. +- **If Nx complains about resolving `@cacheplane/licensing`** during test or build, first report the failure back to the controller. Do not unilaterally edit `libs/agent/tsconfig.json` (especially `baseUrl`) or `tsconfig.base.json`. + +- [ ] **Step 1: Expose the testing helpers from the licensing index** Append to `libs/licensing/src/index.ts`: @@ -1581,19 +1592,31 @@ Append to `libs/licensing/src/index.ts`: // own tests; downstream consumers should not rely on these. export { generateKeyPair, signLicense } from './lib/testing/keypair'; export type { DevKeyPair } from './lib/testing/keypair'; +export { __resetRunLicenseCheckStateForTests } from './lib/run-license-check'; +export { __resetNagStateForTests } from './lib/nag'; ``` - [ ] **Step 2: Replace `libs/agent/src/lib/agent.provider.spec.ts` with the updated test suite** ```ts // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import { describe, it, expect, vi } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { provideAgent, AGENT_CONFIG } from './agent.provider'; import { MockAgentTransport } from './transport/mock-stream.transport'; -import { signLicense, generateKeyPair } from '@cacheplane/licensing'; +import { + signLicense, + generateKeyPair, + __resetRunLicenseCheckStateForTests, + __resetNagStateForTests, +} from '@cacheplane/licensing'; describe('provideAgent', () => { + beforeEach(() => { + __resetRunLicenseCheckStateForTests(); + __resetNagStateForTests(); + }); + it('provides AGENT_CONFIG token', () => { TestBed.configureTestingModule({ providers: [provideAgent({ apiUrl: 'https://api.example.com' })], @@ -1626,7 +1649,15 @@ describe('provideAgent', () => { kp.privateKey, ); TestBed.configureTestingModule({ - providers: [provideAgent({ apiUrl: '', license: token })], + providers: [ + provideAgent({ + apiUrl: '', + license: token, + // @internal hook — verifies against the ephemeral pair so the test + // doesn't need to know/mint the production public key. + __licensePublicKey: kp.publicKey, + }), + ], }); TestBed.inject(AGENT_CONFIG); // Allow microtasks from the ed25519 verify + telemetry fire-and-forget. @@ -1653,7 +1684,7 @@ describe('provideAgent', () => { - [ ] **Step 3: Run tests to verify they fail** Run: `npx nx test agent` -Expected: FAIL — `license` not a known property of `AgentConfig`. +Expected: FAIL — `license` / `__licensePublicKey` not known properties of `AgentConfig`, and `@cacheplane/licensing` doesn't yet export the reset helpers. - [ ] **Step 4: Implement provider changes** @@ -1692,13 +1723,21 @@ export interface AgentConfig { * Test-only env hint override. Not part of the stable API. */ __licenseEnvHint?: { isNoncommercial: boolean }; + /** + * @internal + * Test-only public-key override. Defaults to the compile-time embedded + * `LICENSE_PUBLIC_KEY`. Not part of the stable API. + */ + __licensePublicKey?: Uint8Array; } export const AGENT_CONFIG = new InjectionToken('AGENT_CONFIG'); function inferNoncommercial(): boolean { - if (typeof process !== 'undefined' && process.env) { - return process.env.NODE_ENV !== 'production'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = (globalThis as any)['process']; + if (proc && proc.env) { + return proc.env['NODE_ENV'] !== 'production'; } return false; } @@ -1713,7 +1752,7 @@ export function provideAgent(config: AgentConfig): Provider { package: PACKAGE_NAME, version: PACKAGE_VERSION, token: config.license, - publicKey: LICENSE_PUBLIC_KEY, + publicKey: config.__licensePublicKey ?? LICENSE_PUBLIC_KEY, telemetryEndpoint: TELEMETRY_ENDPOINT, isNoncommercial: config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(), @@ -1736,6 +1775,8 @@ Edit `libs/agent/package.json` — add to the `peerDependencies` block: Run: `npx nx test agent` Expected: PASS — all 4 tests green. +**If tests fail:** stop and report the exact failure to the controller. Do not create a dev private-key fixture, do not alter `keypair.ts`, do not monkeypatch `sha512Async`, do not edit `tsconfig.base.json` or `libs/agent/tsconfig.json`. + - [ ] **Step 7: Verify agent still builds** Run: `npx nx build agent` @@ -1758,6 +1799,13 @@ git commit -m "feat(agent): run license check at provider init" - Create: `libs/render/src/lib/provide-render.spec.ts` - Modify: `libs/render/package.json` +**Guardrails (same as T10; read before starting):** + +- Do not commit any private key to the repo. `libs/licensing/fixtures/` contains only `dev-public-key.hex`. +- Do not modify `libs/licensing/src/lib/testing/keypair.ts`, `libs/licensing/tsconfig.lib.json`, or `libs/licensing/project.json`. +- Mirror agent's `__licensePublicKey` override on `RenderConfig` for symmetry. +- If tests or build fail due to jsdom/Nx issues, stop and report to the controller rather than patching `test-setup.ts` or `tsconfig.base.json` unilaterally. + - [ ] **Step 1: Write the failing test** `libs/render/src/lib/provide-render.spec.ts`: @@ -1767,9 +1815,15 @@ git commit -m "feat(agent): run license check at provider init" import { beforeEach, describe, it, expect, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { provideRender, RENDER_CONFIG } from './provide-render'; +import { + __resetRunLicenseCheckStateForTests, + __resetNagStateForTests, +} from '@cacheplane/licensing'; describe('provideRender', () => { beforeEach(() => { + __resetRunLicenseCheckStateForTests(); + __resetNagStateForTests(); globalThis.console.warn = vi.fn(); }); @@ -1809,6 +1863,12 @@ In `libs/render/src/lib/render.types.ts`, add to the `RenderConfig` interface: * Test-only env hint override. Not part of the stable API. */ __licenseEnvHint?: { isNoncommercial: boolean }; + /** + * @internal + * Test-only public-key override. Defaults to the compile-time embedded + * `LICENSE_PUBLIC_KEY`. Not part of the stable API. + */ + __licensePublicKey?: Uint8Array; ``` - [ ] **Step 3: Implement provider changes** @@ -1830,8 +1890,10 @@ const PACKAGE_VERSION = const TELEMETRY_ENDPOINT = 'https://telemetry.cacheplane.dev/v1/ping'; function inferNoncommercial(): boolean { - if (typeof process !== 'undefined' && process.env) { - return process.env.NODE_ENV !== 'production'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = (globalThis as any)['process']; + if (proc && proc.env) { + return proc.env['NODE_ENV'] !== 'production'; } return false; } @@ -1843,7 +1905,7 @@ export function provideRender(config: RenderConfig) { package: PACKAGE_NAME, version: PACKAGE_VERSION, token: config.license, - publicKey: LICENSE_PUBLIC_KEY, + publicKey: config.__licensePublicKey ?? LICENSE_PUBLIC_KEY, telemetryEndpoint: TELEMETRY_ENDPOINT, isNoncommercial: config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(), @@ -1868,6 +1930,8 @@ Edit `libs/render/package.json` — add to the `peerDependencies` block: Run: `npx nx test render` Expected: PASS — new spec green, existing render tests still green. +**If tests or build fail:** stop and report the exact failure to the controller. + - [ ] **Step 6: Verify render still builds** Run: `npx nx build render` @@ -1889,6 +1953,13 @@ git commit -m "feat(render): run license check at provider init" - Create: `libs/chat/src/lib/provide-chat.spec.ts` - Modify: `libs/chat/package.json` +**Guardrails (same as T10; read before starting):** + +- Do not commit any private key to the repo. `libs/licensing/fixtures/` contains only `dev-public-key.hex`. +- Do not modify `libs/licensing/src/lib/testing/keypair.ts`, `libs/licensing/tsconfig.lib.json`, or `libs/licensing/project.json`. +- Mirror agent's `__licensePublicKey` override on `ChatConfig` for symmetry. +- If tests or build fail due to jsdom/Nx issues, stop and report to the controller rather than patching `test-setup.ts` or `tsconfig.base.json` unilaterally. + - [ ] **Step 1: Write the failing test** `libs/chat/src/lib/provide-chat.spec.ts`: @@ -1898,9 +1969,15 @@ git commit -m "feat(render): run license check at provider init" import { beforeEach, describe, it, expect, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { provideChat, CHAT_CONFIG } from './provide-chat'; +import { + __resetRunLicenseCheckStateForTests, + __resetNagStateForTests, +} from '@cacheplane/licensing'; describe('provideChat', () => { beforeEach(() => { + __resetRunLicenseCheckStateForTests(); + __resetNagStateForTests(); globalThis.console.warn = vi.fn(); }); @@ -1947,8 +2024,10 @@ const PACKAGE_VERSION = const TELEMETRY_ENDPOINT = 'https://telemetry.cacheplane.dev/v1/ping'; function inferNoncommercial(): boolean { - if (typeof process !== 'undefined' && process.env) { - return process.env.NODE_ENV !== 'production'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = (globalThis as any)['process']; + if (proc && proc.env) { + return proc.env['NODE_ENV'] !== 'production'; } return false; } @@ -1967,6 +2046,12 @@ export interface ChatConfig { * Test-only env hint override. Not part of the stable API. */ __licenseEnvHint?: { isNoncommercial: boolean }; + /** + * @internal + * Test-only public-key override. Defaults to the compile-time embedded + * `LICENSE_PUBLIC_KEY`. Not part of the stable API. + */ + __licensePublicKey?: Uint8Array; } export const CHAT_CONFIG = new InjectionToken('CHAT_CONFIG'); @@ -1976,7 +2061,7 @@ export function provideChat(config: ChatConfig) { package: PACKAGE_NAME, version: PACKAGE_VERSION, token: config.license, - publicKey: LICENSE_PUBLIC_KEY, + publicKey: config.__licensePublicKey ?? LICENSE_PUBLIC_KEY, telemetryEndpoint: TELEMETRY_ENDPOINT, isNoncommercial: config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(), @@ -2001,6 +2086,8 @@ Edit `libs/chat/package.json` — add to the `peerDependencies` block: Run: `npx nx test chat` Expected: PASS — new spec green, existing chat tests still green. +**If tests or build fail:** stop and report the exact failure to the controller. + - [ ] **Step 5: Verify chat still builds** Run: `npx nx build chat` From fd95400f585057b35ba9bd429731bdb3895b1260 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 09:30:47 -0700 Subject: [PATCH 12/51] feat(agent): run license check at provider init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fires a fire-and-forget runLicenseCheck() at provideAgent() construction, threading the user-supplied license through to offline Ed25519 verification against the build-time embedded LICENSE_PUBLIC_KEY. Never blocks DI. Adds an @internal __licensePublicKey?: Uint8Array hook on AgentConfig so tests can verify against an ephemeral pair without compiling a second key into the package — the test mints generateKeyPair() at runtime and passes kp.publicKey through the hook. Nothing in the repo signs with or stores a private key; the only fixture in libs/licensing/fixtures/ remains the public key generated at prebuild from CACHEPLANE_LICENSE_PUBLIC_KEY or the deterministic dev fallback. Carves testing helpers into a source-only @cacheplane/licensing/testing subpath (libs/licensing/src/testing.ts) registered via tsconfig paths and excluded from tsconfig.lib.json so the published dist/ stays free of sign/verify helpers. Downstream consumers cannot import @cacheplane/licensing/testing. Drops the baseUrl: "." override from libs/agent/tsconfig.json because it shifted path resolution relative to the agent dir and broke @cacheplane/ licensing resolution; chat and render tsconfigs never set it either. Adds a scoped sha512Async patch to libs/agent/src/test-setup.ts: @noble/ ed25519 calls crypto.subtle.digest() which jsdom rejects for cross-realm TypedArrays. The patch routes sha512 through Node's crypto module in the agent test env only — no effect on production code or the published package. Revises the plan doc in the same commit so T11/T12 pick up the corrected testing-subpath / sha512-patch / tsconfig pattern. 40/40 agent tests pass; 30/30 licensing tests pass; agent build succeeds and dist/libs/licensing contains no testing helpers. Co-Authored-By: Claude Opus 4 --- ...2026-04-19-license-verification-library.md | 85 ++++++++++++++++--- libs/agent/package.json | 1 + libs/agent/src/lib/agent.provider.spec.ts | 59 ++++++++++++- libs/agent/src/lib/agent.provider.ts | 68 ++++++++++----- libs/agent/src/test-setup.ts | 18 ++++ libs/agent/tsconfig.json | 3 +- libs/licensing/src/testing.ts | 8 ++ libs/licensing/tsconfig.lib.json | 2 +- tsconfig.base.json | 1 + 9 files changed, 206 insertions(+), 39 deletions(-) create mode 100644 libs/licensing/src/testing.ts diff --git a/docs/superpowers/plans/2026-04-19-license-verification-library.md b/docs/superpowers/plans/2026-04-19-license-verification-library.md index da6c1a506..97fdfee2b 100644 --- a/docs/superpowers/plans/2026-04-19-license-verification-library.md +++ b/docs/superpowers/plans/2026-04-19-license-verification-library.md @@ -1568,34 +1568,87 @@ git commit -m "docs(licensing): add README and shared test fixtures" ## Task 10: Integrate license check into `@cacheplane/angular` **Files:** -- Modify: `libs/licensing/src/index.ts` +- Create: `libs/licensing/src/testing.ts` +- Modify: `tsconfig.base.json` (add `@cacheplane/licensing/testing` path) +- Modify: `libs/licensing/tsconfig.lib.json` (exclude `src/testing.ts`) +- Modify: `libs/agent/tsconfig.json` (remove `baseUrl: "."` override) +- Modify: `libs/agent/src/test-setup.ts` (scoped sha512 patch) - Modify: `libs/agent/src/lib/agent.provider.ts` - Modify: `libs/agent/src/lib/agent.provider.spec.ts` - Modify: `libs/agent/package.json` **Guardrails (read before starting):** -- **Do NOT commit any private key to the repo.** The only fixture under `libs/licensing/fixtures/` is `dev-public-key.hex`. There is no `dev-private-key.hex`. Do not create one. Every test must mint its keypair at runtime via `generateKeyPair()` from `@cacheplane/licensing`. +- **Do NOT commit any private key to the repo.** The only fixture under `libs/licensing/fixtures/` is `dev-public-key.hex`. There is no `dev-private-key.hex`. Do not create one. Every test must mint its keypair at runtime via `generateKeyPair()` from `@cacheplane/licensing/testing`. - **Do NOT modify `libs/licensing/src/lib/testing/keypair.ts`** in this task. `generateKeyPair()` must stay non-deterministic. -- **Do NOT change `libs/licensing/tsconfig.lib.json`.** The `testing/**` exclude stays; testing helpers must not ship in the published `dist/`. -- **Do NOT change `libs/licensing/project.json`** or the prebuild wiring. +- **Do NOT modify `libs/licensing/project.json`** or the prebuild wiring. - **Architectural key:** the provider hardcodes `LICENSE_PUBLIC_KEY` in production, but `AgentConfig` gains an `@internal __licensePublicKey?: Uint8Array` escape hatch so tests can verify against an ephemeral pair without touching the compile-time constant. Mirror the shape of the existing `__licenseEnvHint` hook. -- **If tests fail in jsdom** with an `ed25519`/`SubtleCrypto` cross-realm `ArrayBuffer` error, first report the failure back to the controller (do not silently monkeypatch `sha512Async` in `test-setup.ts`). We'll decide together whether the patch is warranted. -- **If Nx complains about resolving `@cacheplane/licensing`** during test or build, first report the failure back to the controller. Do not unilaterally edit `libs/agent/tsconfig.json` (especially `baseUrl`) or `tsconfig.base.json`. +- **Testing helpers stay source-only.** A new `@cacheplane/licensing/testing` subpath maps via TS paths to `libs/licensing/src/testing.ts`. `src/testing.ts` is excluded from `tsconfig.lib.json` so testing helpers never ship in the published `dist/`. Downstream consumers cannot import `@cacheplane/licensing/testing`. -- [ ] **Step 1: Expose the testing helpers from the licensing index** +- [ ] **Step 1: Create the testing subpath entry** -Append to `libs/licensing/src/index.ts`: +Create `libs/licensing/src/testing.ts`: ```ts -// Testing subpath — not a stable public API. Safe to use from this monorepo's -// own tests; downstream consumers should not rely on these. +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Monorepo-internal test helpers. NOT part of the published package — +// excluded from `tsconfig.lib.json` so nothing here ships in dist. +// Downstream consumers cannot import `@cacheplane/licensing/testing`. export { generateKeyPair, signLicense } from './lib/testing/keypair'; export type { DevKeyPair } from './lib/testing/keypair'; export { __resetRunLicenseCheckStateForTests } from './lib/run-license-check'; export { __resetNagStateForTests } from './lib/nag'; ``` +- [ ] **Step 1b: Register the path alias in `tsconfig.base.json`** + +In the `compilerOptions.paths` block, add this line directly below `"@cacheplane/licensing"`: + +```json + "@cacheplane/licensing/testing": ["libs/licensing/src/testing.ts"], +``` + +- [ ] **Step 1c: Exclude `src/testing.ts` from the licensing lib build** + +In `libs/licensing/tsconfig.lib.json`, add `"src/testing.ts"` to the existing exclude: + +```json + "exclude": ["src/**/*.spec.ts", "src/lib/testing/**", "src/testing.ts"] +``` + +- [ ] **Step 1d: Remove the `baseUrl` override from `libs/agent/tsconfig.json`** + +`libs/agent/tsconfig.json` currently sets `"baseUrl": "."`, which shifts path resolution relative to the agent dir and prevents `@cacheplane/licensing` from resolving. Delete that line — the `chat` and `render` tsconfigs don't have it either. + +- [ ] **Step 1e: Patch `sha512Async` in `libs/agent/src/test-setup.ts`** + +`@noble/ed25519` defaults to `crypto.subtle.digest('sha-512', ...)`. jsdom's SubtleCrypto rejects TypedArrays from the Node realm with "2nd argument is not instance of ArrayBuffer" (cross-realm instanceof). Scope a Node-crypto replacement to the agent test env only: + +```ts +import { getTestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; +import * as ed from '@noble/ed25519'; +import { createHash } from 'node:crypto'; + +// jsdom's SubtleCrypto rejects cross-realm TypedArrays. Route sha512 through +// Node's crypto module, which has no cross-realm constraints. Scoped to agent +// test-setup only — does not affect production code or the published package. +ed.etc.sha512Async = async (...messages: Uint8Array[]): Promise => { + const hash = createHash('sha512'); + for (const m of messages) hash.update(m); + return new Uint8Array(hash.digest()); +}; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), + { teardown: { destroyAfterEach: true } }, +); +``` + - [ ] **Step 2: Replace `libs/agent/src/lib/agent.provider.spec.ts` with the updated test suite** ```ts @@ -1609,7 +1662,7 @@ import { generateKeyPair, __resetRunLicenseCheckStateForTests, __resetNagStateForTests, -} from '@cacheplane/licensing'; +} from '@cacheplane/licensing/testing'; describe('provideAgent', () => { beforeEach(() => { @@ -1785,7 +1838,11 @@ Expected: build succeeds. - [ ] **Step 8: Commit** ```bash -git add libs/agent/src/lib/agent.provider.ts libs/agent/src/lib/agent.provider.spec.ts libs/agent/package.json libs/licensing/src/index.ts +git add libs/licensing/src/testing.ts libs/licensing/tsconfig.lib.json \ + tsconfig.base.json \ + libs/agent/tsconfig.json libs/agent/src/test-setup.ts \ + libs/agent/src/lib/agent.provider.ts libs/agent/src/lib/agent.provider.spec.ts \ + libs/agent/package.json git commit -m "feat(agent): run license check at provider init" ``` @@ -1818,7 +1875,7 @@ import { provideRender, RENDER_CONFIG } from './provide-render'; import { __resetRunLicenseCheckStateForTests, __resetNagStateForTests, -} from '@cacheplane/licensing'; +} from '@cacheplane/licensing/testing'; describe('provideRender', () => { beforeEach(() => { @@ -1972,7 +2029,7 @@ import { provideChat, CHAT_CONFIG } from './provide-chat'; import { __resetRunLicenseCheckStateForTests, __resetNagStateForTests, -} from '@cacheplane/licensing'; +} from '@cacheplane/licensing/testing'; describe('provideChat', () => { beforeEach(() => { diff --git a/libs/agent/package.json b/libs/agent/package.json index a85f99e80..54465b89a 100644 --- a/libs/agent/package.json +++ b/libs/agent/package.json @@ -2,6 +2,7 @@ "name": "@cacheplane/angular", "version": "0.0.1", "peerDependencies": { + "@cacheplane/licensing": "^0.0.1", "@angular/core": "^20.0.0 || ^21.0.0", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", diff --git a/libs/agent/src/lib/agent.provider.spec.ts b/libs/agent/src/lib/agent.provider.spec.ts index e37bf91a4..f2066d388 100644 --- a/libs/agent/src/lib/agent.provider.spec.ts +++ b/libs/agent/src/lib/agent.provider.spec.ts @@ -1,9 +1,21 @@ -import { describe, it, expect } from 'vitest'; +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { beforeEach, describe, it, expect, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { provideAgent, AGENT_CONFIG } from './agent.provider'; import { MockAgentTransport } from './transport/mock-stream.transport'; +import { + signLicense, + generateKeyPair, + __resetRunLicenseCheckStateForTests, + __resetNagStateForTests, +} from '@cacheplane/licensing/testing'; describe('provideAgent', () => { + beforeEach(() => { + __resetRunLicenseCheckStateForTests(); + __resetNagStateForTests(); + }); + it('provides AGENT_CONFIG token', () => { TestBed.configureTestingModule({ providers: [provideAgent({ apiUrl: 'https://api.example.com' })], @@ -20,4 +32,49 @@ describe('provideAgent', () => { const config = TestBed.inject(AGENT_CONFIG); expect(config.transport).toBe(transport); }); + + it('runs a silent license check when a valid license is supplied', async () => { + const warn = vi.fn(); + globalThis.console.warn = warn; + const kp = await generateKeyPair(); + const token = await signLicense( + { + sub: 'cus_test', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 2_000_000_000, + seats: 1, + }, + kp.privateKey, + ); + TestBed.configureTestingModule({ + providers: [ + provideAgent({ + apiUrl: '', + license: token, + // @internal hook — verifies against the ephemeral pair so the test + // doesn't need to know/mint the production public key. + __licensePublicKey: kp.publicKey, + }), + ], + }); + TestBed.inject(AGENT_CONFIG); + // Allow microtasks from the ed25519 verify + telemetry fire-and-forget. + await new Promise((r) => setTimeout(r, 0)); + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns when license is missing and env is production-like', async () => { + const warn = vi.fn(); + globalThis.console.warn = warn; + TestBed.configureTestingModule({ + providers: [ + provideAgent({ apiUrl: '', __licenseEnvHint: { isNoncommercial: false } }), + ], + }); + TestBed.inject(AGENT_CONFIG); + await new Promise((r) => setTimeout(r, 0)); + const calls = warn.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes('[cacheplane] @cacheplane/angular'))).toBe(true); + }); }); diff --git a/libs/agent/src/lib/agent.provider.ts b/libs/agent/src/lib/agent.provider.ts index 02f919577..c14d134f0 100644 --- a/libs/agent/src/lib/agent.provider.ts +++ b/libs/agent/src/lib/agent.provider.ts @@ -1,7 +1,19 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { InjectionToken, Provider } from '@angular/core'; +import { runLicenseCheck, LICENSE_PUBLIC_KEY } from '@cacheplane/licensing'; import { AgentTransport } from './agent.types'; +const PACKAGE_NAME = '@cacheplane/angular'; +// Wired up by the release pipeline — imported lazily to avoid a hard build-time +// dependency on package.json. +declare const __CACHEPLANE_AGENT_VERSION__: string | undefined; +const PACKAGE_VERSION = + typeof __CACHEPLANE_AGENT_VERSION__ !== 'undefined' + ? __CACHEPLANE_AGENT_VERSION__ + : '0.0.0-dev'; +const TELEMETRY_ENDPOINT = + 'https://telemetry.cacheplane.dev/v1/ping'; + /** * Global configuration for agent instances. * Properties set here serve as defaults that can be overridden per-call. @@ -11,33 +23,47 @@ export interface AgentConfig { apiUrl?: string; /** Custom transport implementation. Defaults to {@link FetchStreamTransport}. */ transport?: AgentTransport; + /** Signed license token from cacheplane.dev. Optional; omitted in dev. */ + license?: string; + /** + * @internal + * Test-only env hint override. Not part of the stable API. + */ + __licenseEnvHint?: { isNoncommercial: boolean }; + /** + * @internal + * Test-only public-key override. Defaults to the compile-time embedded + * `LICENSE_PUBLIC_KEY`. Not part of the stable API. + */ + __licensePublicKey?: Uint8Array; } -export const AGENT_CONFIG = - new InjectionToken('AGENT_CONFIG'); +export const AGENT_CONFIG = new InjectionToken('AGENT_CONFIG'); + +function inferNoncommercial(): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = (globalThis as any)['process']; + if (proc && proc.env) { + return proc.env['NODE_ENV'] !== 'production'; + } + return false; +} /** * Angular provider factory that registers global defaults for all * agent instances in the application. - * - * Add to your `app.config.ts` or module providers array. - * - * @param config - Global configuration merged with per-call options - * @returns An Angular Provider for dependency injection - * - * @example - * ```typescript - * // app.config.ts - * export const appConfig: ApplicationConfig = { - * providers: [ - * provideAgent({ apiUrl: 'http://localhost:2024' }), - * ], - * }; - * ``` */ export function provideAgent(config: AgentConfig): Provider { - return { - provide: AGENT_CONFIG, - useValue: config, - }; + // Fire-and-forget license check. Never blocks DI resolution. + void runLicenseCheck({ + package: PACKAGE_NAME, + version: PACKAGE_VERSION, + token: config.license, + publicKey: config.__licensePublicKey ?? LICENSE_PUBLIC_KEY, + telemetryEndpoint: TELEMETRY_ENDPOINT, + isNoncommercial: + config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(), + }); + + return { provide: AGENT_CONFIG, useValue: config }; } diff --git a/libs/agent/src/test-setup.ts b/libs/agent/src/test-setup.ts index ca3d8a2b3..fe740cd63 100644 --- a/libs/agent/src/test-setup.ts +++ b/libs/agent/src/test-setup.ts @@ -3,6 +3,24 @@ import { BrowserTestingModule, platformBrowserTesting, } from '@angular/platform-browser/testing'; +import * as ed from '@noble/ed25519'; +import { createHash } from 'node:crypto'; + +// jsdom's SubtleCrypto implementation rejects TypedArray inputs that originate +// from the Node realm with "2nd argument is not instance of ArrayBuffer" — a +// cross-realm instanceof check. @noble/ed25519 defaults to SubtleCrypto, so +// any call to ed.getPublicKeyAsync / signAsync / verifyAsync in a jsdom test +// environment hits this. Route sha512 through Node's crypto module instead, +// which has no cross-realm constraints and produces the same digest. +// +// Scoped to libs/agent test-setup only — this does not affect production code +// or the published package (@cacheplane/angular). The @noble/ed25519 default +// remains in place for all non-test consumers. +ed.etc.sha512Async = async (...messages: Uint8Array[]): Promise => { + const hash = createHash('sha512'); + for (const m of messages) hash.update(m); + return new Uint8Array(hash.digest()); +}; getTestBed().initTestEnvironment( BrowserTestingModule, diff --git a/libs/agent/tsconfig.json b/libs/agent/tsconfig.json index df5104e30..da190b437 100644 --- a/libs/agent/tsconfig.json +++ b/libs/agent/tsconfig.json @@ -5,8 +5,7 @@ "noPropertyAccessFromIndexSignature": true, "module": "preserve", "emitDeclarationOnly": false, - "composite": false, - "baseUrl": "." + "composite": false }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/libs/licensing/src/testing.ts b/libs/licensing/src/testing.ts new file mode 100644 index 000000000..81745d7ad --- /dev/null +++ b/libs/licensing/src/testing.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Monorepo-internal test helpers. NOT part of the published package — +// excluded from `tsconfig.lib.json` so nothing here ships in dist. +// Downstream consumers cannot import `@cacheplane/licensing/testing`. +export { generateKeyPair, signLicense } from './lib/testing/keypair'; +export type { DevKeyPair } from './lib/testing/keypair'; +export { __resetRunLicenseCheckStateForTests } from './lib/run-license-check'; +export { __resetNagStateForTests } from './lib/nag'; diff --git a/libs/licensing/tsconfig.lib.json b/libs/licensing/tsconfig.lib.json index e6d236a47..bb6725ea8 100644 --- a/libs/licensing/tsconfig.lib.json +++ b/libs/licensing/tsconfig.lib.json @@ -2,5 +2,5 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts", "src/lib/testing/**"] + "exclude": ["src/**/*.spec.ts", "src/lib/testing/**", "src/testing.ts"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 43114cc8f..b3d42e74a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -29,6 +29,7 @@ "@cacheplane/partial-json": ["libs/partial-json/src/index.ts"], "@cacheplane/a2ui": ["libs/a2ui/src/index.ts"], "@cacheplane/licensing": ["libs/licensing/src/index.ts"], + "@cacheplane/licensing/testing": ["libs/licensing/src/testing.ts"], "@cacheplane/example-layouts": ["libs/example-layouts/src/public-api.ts"] }, "skipLibCheck": true, From dd41f24512a7764e5a5bfc3d48ffd605f981b3b3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 09:32:31 -0700 Subject: [PATCH 13/51] docs: call out baseUrl removal in T11 render integration Render's tsconfig.json has the same baseUrl: "." override as agent had pre-T10, which will break @cacheplane/licensing resolution the same way. Call it out explicitly as Step 0 so T11 doesn't rediscover it. Co-Authored-By: Claude Opus 4 --- .../2026-04-19-license-verification-library.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-04-19-license-verification-library.md b/docs/superpowers/plans/2026-04-19-license-verification-library.md index 97fdfee2b..69c99d282 100644 --- a/docs/superpowers/plans/2026-04-19-license-verification-library.md +++ b/docs/superpowers/plans/2026-04-19-license-verification-library.md @@ -1851,17 +1851,23 @@ git commit -m "feat(agent): run license check at provider init" ## Task 11: Integrate license check into `@cacheplane/render` **Files:** +- Modify: `libs/render/tsconfig.json` (remove `baseUrl: "."` override, same reason as agent in T10) - Modify: `libs/render/src/lib/provide-render.ts` - Modify: `libs/render/src/lib/render.types.ts` - Create: `libs/render/src/lib/provide-render.spec.ts` - Modify: `libs/render/package.json` -**Guardrails (same as T10; read before starting):** +**Guardrails:** - Do not commit any private key to the repo. `libs/licensing/fixtures/` contains only `dev-public-key.hex`. -- Do not modify `libs/licensing/src/lib/testing/keypair.ts`, `libs/licensing/tsconfig.lib.json`, or `libs/licensing/project.json`. +- Do not modify `libs/licensing/src/lib/testing/keypair.ts`, `libs/licensing/tsconfig.lib.json`, `libs/licensing/project.json`, or the `@cacheplane/licensing/testing` subpath wiring set up in T10. - Mirror agent's `__licensePublicKey` override on `RenderConfig` for symmetry. -- If tests or build fail due to jsdom/Nx issues, stop and report to the controller rather than patching `test-setup.ts` or `tsconfig.base.json` unilaterally. +- The render test only covers the missing-license warn path (no ed25519 work), so no `sha512Async` patch is needed in `libs/render/src/test-setup.ts`. +- If tests or build fail in unexpected ways, stop and report to the controller. + +- [ ] **Step 0: Remove the `baseUrl` override from `libs/render/tsconfig.json`** + +Same as the agent fix in T10 Step 1d — `baseUrl: "."` in the per-lib tsconfig shifts path resolution and breaks `@cacheplane/licensing`. Delete the `"baseUrl": "."` line. - [ ] **Step 1: Write the failing test** @@ -1997,7 +2003,7 @@ Expected: build succeeds. - [ ] **Step 7: Commit** ```bash -git add libs/render/src/lib/provide-render.ts libs/render/src/lib/provide-render.spec.ts libs/render/src/lib/render.types.ts libs/render/package.json +git add libs/render/tsconfig.json libs/render/src/lib/provide-render.ts libs/render/src/lib/provide-render.spec.ts libs/render/src/lib/render.types.ts libs/render/package.json git commit -m "feat(render): run license check at provider init" ``` From ad8a5e73295611736de534c7cf22c1cdfcd58133 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 09:35:17 -0700 Subject: [PATCH 14/51] feat(render): run license check at provider init --- libs/render/package.json | 3 +- libs/render/src/lib/provide-render.spec.ts | 34 +++++++++++++++++++++- libs/render/src/lib/provide-render.ts | 28 ++++++++++++++++++ libs/render/src/lib/render.types.ts | 13 +++++++++ libs/render/tsconfig.json | 3 +- 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/libs/render/package.json b/libs/render/package.json index 288a58349..1c47e28d1 100644 --- a/libs/render/package.json +++ b/libs/render/package.json @@ -4,7 +4,8 @@ "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", - "@json-render/core": "^0.16.0" + "@json-render/core": "^0.16.0", + "@cacheplane/licensing": "^0.0.1" }, "license": "PolyForm-Noncommercial-1.0.0", "sideEffects": false diff --git a/libs/render/src/lib/provide-render.spec.ts b/libs/render/src/lib/provide-render.spec.ts index 769ec3ead..6696bdff1 100644 --- a/libs/render/src/lib/provide-render.spec.ts +++ b/libs/render/src/lib/provide-render.spec.ts @@ -1,15 +1,25 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import { describe, it, expect } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { Component } from '@angular/core'; import { provideRender, RENDER_CONFIG } from './provide-render'; import { defineAngularRegistry } from './define-angular-registry'; import type { RenderConfig } from './render.types'; +import { + __resetRunLicenseCheckStateForTests, + __resetNagStateForTests, +} from '@cacheplane/licensing/testing'; @Component({ selector: 'render-test-card', standalone: true, template: '
card
' }) class TestCardComponent {} describe('provideRender', () => { + beforeEach(() => { + __resetRunLicenseCheckStateForTests(); + __resetNagStateForTests(); + globalThis.console.warn = vi.fn(); + }); + it('should provide RenderConfig via injection token', () => { const registry = defineAngularRegistry({ Card: TestCardComponent }); const config: RenderConfig = { registry }; @@ -23,4 +33,26 @@ describe('provideRender', () => { const injectedConfig = TestBed.inject(RENDER_CONFIG, null); expect(injectedConfig).toBeNull(); }); + + it('provides RENDER_CONFIG token', () => { + TestBed.configureTestingModule({ providers: [provideRender({})] }); + const config = TestBed.inject(RENDER_CONFIG); + expect(config).toBeDefined(); + }); + + it('warns when license is missing in a production-like env', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRender({ __licenseEnvHint: { isNoncommercial: false } }), + ], + }); + TestBed.inject(RENDER_CONFIG); + await new Promise((r) => setTimeout(r, 0)); + const warn = globalThis.console.warn as ReturnType; + expect( + warn.mock.calls.some((c) => + String(c[0]).includes('[cacheplane] @cacheplane/render'), + ), + ).toBe(true); + }); }); diff --git a/libs/render/src/lib/provide-render.ts b/libs/render/src/lib/provide-render.ts index 3577f4f34..934129f53 100644 --- a/libs/render/src/lib/provide-render.ts +++ b/libs/render/src/lib/provide-render.ts @@ -1,10 +1,38 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import { runLicenseCheck, LICENSE_PUBLIC_KEY } from '@cacheplane/licensing'; import type { RenderConfig } from './render.types'; +const PACKAGE_NAME = '@cacheplane/render'; +declare const __CACHEPLANE_RENDER_VERSION__: string | undefined; +const PACKAGE_VERSION = + typeof __CACHEPLANE_RENDER_VERSION__ !== 'undefined' + ? __CACHEPLANE_RENDER_VERSION__ + : '0.0.0-dev'; +const TELEMETRY_ENDPOINT = 'https://telemetry.cacheplane.dev/v1/ping'; + +function inferNoncommercial(): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = (globalThis as any)['process']; + if (proc && proc.env) { + return proc.env['NODE_ENV'] !== 'production'; + } + return false; +} + export const RENDER_CONFIG = new InjectionToken('RENDER_CONFIG'); export function provideRender(config: RenderConfig) { + void runLicenseCheck({ + package: PACKAGE_NAME, + version: PACKAGE_VERSION, + token: config.license, + publicKey: config.__licensePublicKey ?? LICENSE_PUBLIC_KEY, + telemetryEndpoint: TELEMETRY_ENDPOINT, + isNoncommercial: + config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(), + }); + return makeEnvironmentProviders([ { provide: RENDER_CONFIG, useValue: config }, ]); diff --git a/libs/render/src/lib/render.types.ts b/libs/render/src/lib/render.types.ts index b439e081b..0dc1c88a2 100644 --- a/libs/render/src/lib/render.types.ts +++ b/libs/render/src/lib/render.types.ts @@ -29,4 +29,17 @@ export interface RenderConfig { store?: StateStore; functions?: Record; handlers?: Record) => unknown | Promise>; + /** Signed license token from cacheplane.dev. Optional; omitted in dev. */ + license?: string; + /** + * @internal + * Test-only env hint override. Not part of the stable API. + */ + __licenseEnvHint?: { isNoncommercial: boolean }; + /** + * @internal + * Test-only public-key override. Defaults to the compile-time embedded + * `LICENSE_PUBLIC_KEY`. Not part of the stable API. + */ + __licensePublicKey?: Uint8Array; } diff --git a/libs/render/tsconfig.json b/libs/render/tsconfig.json index df5104e30..da190b437 100644 --- a/libs/render/tsconfig.json +++ b/libs/render/tsconfig.json @@ -5,8 +5,7 @@ "noPropertyAccessFromIndexSignature": true, "module": "preserve", "emitDeclarationOnly": false, - "composite": false, - "baseUrl": "." + "composite": false }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, From 6addcd17b4c70752d0964a401e59d5517d5e8a7d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 09:43:22 -0700 Subject: [PATCH 15/51] feat(chat): run license check at provider init --- libs/chat/package.json | 1 + libs/chat/src/lib/provide-chat.spec.ts | 34 ++++++++++++++++++++- libs/chat/src/lib/provide-chat.ts | 41 ++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/libs/chat/package.json b/libs/chat/package.json index e464267fe..34accdb32 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -5,6 +5,7 @@ "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", "@angular/forms": "^20.0.0 || ^21.0.0", + "@cacheplane/licensing": "^0.0.1", "@cacheplane/render": "^0.0.1", "@cacheplane/a2ui": "^0.0.1", "@cacheplane/partial-json": "^0.0.1", diff --git a/libs/chat/src/lib/provide-chat.spec.ts b/libs/chat/src/lib/provide-chat.spec.ts index 083fddf74..f2c71d7d3 100644 --- a/libs/chat/src/lib/provide-chat.spec.ts +++ b/libs/chat/src/lib/provide-chat.spec.ts @@ -1,10 +1,20 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import { describe, it, expect } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { provideChat, CHAT_CONFIG } from './provide-chat'; import type { ChatConfig } from './provide-chat'; +import { + __resetRunLicenseCheckStateForTests, + __resetNagStateForTests, +} from '@cacheplane/licensing/testing'; describe('provideChat', () => { + beforeEach(() => { + __resetRunLicenseCheckStateForTests(); + __resetNagStateForTests(); + globalThis.console.warn = vi.fn(); + }); + it('registers CHAT_CONFIG token with the provided config', () => { const config: ChatConfig = { renderRegistry: undefined }; @@ -32,4 +42,26 @@ describe('provideChat', () => { expect(result).toBeDefined(); expect(typeof result).toBe('object'); }); + + it('provides CHAT_CONFIG token', () => { + TestBed.configureTestingModule({ providers: [provideChat({})] }); + const config = TestBed.inject(CHAT_CONFIG); + expect(config).toBeDefined(); + }); + + it('warns when license is missing in a production-like env', async () => { + TestBed.configureTestingModule({ + providers: [ + provideChat({ __licenseEnvHint: { isNoncommercial: false } }), + ], + }); + TestBed.inject(CHAT_CONFIG); + await new Promise((r) => setTimeout(r, 0)); + const warn = globalThis.console.warn as ReturnType; + expect( + warn.mock.calls.some((c) => + String(c[0]).includes('[cacheplane] @cacheplane/chat'), + ), + ).toBe(true); + }); }); diff --git a/libs/chat/src/lib/provide-chat.ts b/libs/chat/src/lib/provide-chat.ts index 9ce65b02c..ac0954881 100644 --- a/libs/chat/src/lib/provide-chat.ts +++ b/libs/chat/src/lib/provide-chat.ts @@ -1,7 +1,25 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import { runLicenseCheck, LICENSE_PUBLIC_KEY } from '@cacheplane/licensing'; import type { AngularRegistry } from '@cacheplane/render'; +const PACKAGE_NAME = '@cacheplane/chat'; +declare const __CACHEPLANE_CHAT_VERSION__: string | undefined; +const PACKAGE_VERSION = + typeof __CACHEPLANE_CHAT_VERSION__ !== 'undefined' + ? __CACHEPLANE_CHAT_VERSION__ + : '0.0.0-dev'; +const TELEMETRY_ENDPOINT = 'https://telemetry.cacheplane.dev/v1/ping'; + +function inferNoncommercial(): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = (globalThis as any)['process']; + if (proc && proc.env) { + return proc.env['NODE_ENV'] !== 'production'; + } + return false; +} + export interface ChatConfig { /** Default render registry for generative UI components. */ renderRegistry?: AngularRegistry; @@ -9,11 +27,34 @@ export interface ChatConfig { avatarLabel?: string; /** Override the default assistant display name (default: "Assistant"). */ assistantName?: string; + /** Signed license token from cacheplane.dev. Optional; omitted in dev. */ + license?: string; + /** + * @internal + * Test-only env hint override. Not part of the stable API. + */ + __licenseEnvHint?: { isNoncommercial: boolean }; + /** + * @internal + * Test-only public-key override. Defaults to the compile-time embedded + * `LICENSE_PUBLIC_KEY`. Not part of the stable API. + */ + __licensePublicKey?: Uint8Array; } export const CHAT_CONFIG = new InjectionToken('CHAT_CONFIG'); export function provideChat(config: ChatConfig) { + void runLicenseCheck({ + package: PACKAGE_NAME, + version: PACKAGE_VERSION, + token: config.license, + publicKey: config.__licensePublicKey ?? LICENSE_PUBLIC_KEY, + telemetryEndpoint: TELEMETRY_ENDPOINT, + isNoncommercial: + config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(), + }); + return makeEnvironmentProviders([ { provide: CHAT_CONFIG, useValue: config }, ]); From 4c19e29928e70bfb454da9d087afbee230289a20 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 10:38:27 -0700 Subject: [PATCH 16/51] fix(licensing): make library dist ESM-loadable Two scaffolding issues surfaced by the T13 sanity sweep blocked the published @cacheplane/licensing package from loading at runtime: 1. libs/licensing/tsconfig.lib.json inherited emitDeclarationOnly: true from tsconfig.base.json, so `@nx/js:tsc` emitted only .d.ts files. Override with emitDeclarationOnly: false so .js is emitted. 2. Relative imports in licensing source used extensionless specifiers (e.g., `./lib/verify-license`). TS moduleResolution: bundler accepts these at typecheck time but Node ESM requires explicit `.js` at runtime. Add `.js` extensions to all relative imports in the lib build set (src/index.ts + 5 non-spec files under src/lib/). Spec files and src/lib/testing/ are excluded from the lib build, so their imports are untouched. Verification: - npx nx run-many -t test -p licensing,agent,render,chat: all pass - npx nx run-many -t build -p licensing,agent,render,chat: all pass - Node nag-path smoke test against dist/libs/licensing/src/index.js: fires `[cacheplane] @cacheplane/test: no license key detected...` and exits 0 (telemetry failure swallowed). --- libs/licensing/src/index.ts | 24 ++++++++++---------- libs/licensing/src/lib/evaluate-license.ts | 4 ++-- libs/licensing/src/lib/license-public-key.ts | 2 +- libs/licensing/src/lib/nag.ts | 2 +- libs/licensing/src/lib/run-license-check.ts | 8 +++---- libs/licensing/src/lib/verify-license.ts | 2 +- libs/licensing/tsconfig.lib.json | 6 ++++- 7 files changed, 26 insertions(+), 22 deletions(-) diff --git a/libs/licensing/src/index.ts b/libs/licensing/src/index.ts index 2d064b2fa..9ee106e46 100644 --- a/libs/licensing/src/index.ts +++ b/libs/licensing/src/index.ts @@ -1,17 +1,17 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -export type { LicenseClaims, LicenseTier } from './lib/license-token'; -export type { VerifyResult, VerifyReason } from './lib/verify-license'; -export { verifyLicense } from './lib/verify-license'; -export type { LicenseStatus, EvaluateResult, EvaluateOptions } from './lib/evaluate-license'; -export { evaluateLicense } from './lib/evaluate-license'; -export type { EmitNagOptions } from './lib/nag'; -export { emitNag } from './lib/nag'; +export type { LicenseClaims, LicenseTier } from './lib/license-token.js'; +export type { VerifyResult, VerifyReason } from './lib/verify-license.js'; +export { verifyLicense } from './lib/verify-license.js'; +export type { LicenseStatus, EvaluateResult, EvaluateOptions } from './lib/evaluate-license.js'; +export { evaluateLicense } from './lib/evaluate-license.js'; +export type { EmitNagOptions } from './lib/nag.js'; +export { emitNag } from './lib/nag.js'; export type { TelemetryEvent, TelemetryClient, CreateTelemetryClientOptions, -} from './lib/telemetry'; -export { createTelemetryClient } from './lib/telemetry'; -export type { RunLicenseCheckOptions } from './lib/run-license-check'; -export { runLicenseCheck } from './lib/run-license-check'; -export { LICENSE_PUBLIC_KEY } from './lib/license-public-key'; +} from './lib/telemetry.js'; +export { createTelemetryClient } from './lib/telemetry.js'; +export type { RunLicenseCheckOptions } from './lib/run-license-check.js'; +export { runLicenseCheck } from './lib/run-license-check.js'; +export { LICENSE_PUBLIC_KEY } from './lib/license-public-key.js'; diff --git a/libs/licensing/src/lib/evaluate-license.ts b/libs/licensing/src/lib/evaluate-license.ts index cb3cf63ea..d2f0fdffd 100644 --- a/libs/licensing/src/lib/evaluate-license.ts +++ b/libs/licensing/src/lib/evaluate-license.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import type { LicenseClaims } from './license-token'; -import type { VerifyResult } from './verify-license'; +import type { LicenseClaims } from './license-token.js'; +import type { VerifyResult } from './verify-license.js'; export type LicenseStatus = | 'licensed' // valid signed token, not expired diff --git a/libs/licensing/src/lib/license-public-key.ts b/libs/licensing/src/lib/license-public-key.ts index 5f69302b2..50c2d1861 100644 --- a/libs/licensing/src/lib/license-public-key.ts +++ b/libs/licensing/src/lib/license-public-key.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import { LICENSE_PUBLIC_KEY_HEX } from './license-public-key.generated'; +import { LICENSE_PUBLIC_KEY_HEX } from './license-public-key.generated.js'; function hexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); diff --git a/libs/licensing/src/lib/nag.ts b/libs/licensing/src/lib/nag.ts index 0be607a01..f816965c9 100644 --- a/libs/licensing/src/lib/nag.ts +++ b/libs/licensing/src/lib/nag.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import type { EvaluateResult } from './evaluate-license'; +import type { EvaluateResult } from './evaluate-license.js'; export interface EmitNagOptions { /** Fully-qualified npm package name, e.g. "@cacheplane/angular". */ diff --git a/libs/licensing/src/lib/run-license-check.ts b/libs/licensing/src/lib/run-license-check.ts index cd1e9d729..40a7e20f3 100644 --- a/libs/licensing/src/lib/run-license-check.ts +++ b/libs/licensing/src/lib/run-license-check.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import { verifyLicense } from './verify-license'; -import { evaluateLicense, type LicenseStatus } from './evaluate-license'; -import { emitNag } from './nag'; -import { createTelemetryClient } from './telemetry'; +import { verifyLicense } from './verify-license.js'; +import { evaluateLicense, type LicenseStatus } from './evaluate-license.js'; +import { emitNag } from './nag.js'; +import { createTelemetryClient } from './telemetry.js'; export interface RunLicenseCheckOptions { /** Fully-qualified host package name. */ diff --git a/libs/licensing/src/lib/verify-license.ts b/libs/licensing/src/lib/verify-license.ts index fd1cd02ce..ff07fc5a5 100644 --- a/libs/licensing/src/lib/verify-license.ts +++ b/libs/licensing/src/lib/verify-license.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import * as ed from '@noble/ed25519'; -import { parseLicenseToken, type LicenseClaims } from './license-token'; +import { parseLicenseToken, type LicenseClaims } from './license-token.js'; export type VerifyReason = 'malformed' | 'tampered'; diff --git a/libs/licensing/tsconfig.lib.json b/libs/licensing/tsconfig.lib.json index bb6725ea8..0186ab08c 100644 --- a/libs/licensing/tsconfig.lib.json +++ b/libs/licensing/tsconfig.lib.json @@ -1,6 +1,10 @@ { "extends": "./tsconfig.json", - "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true }, + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "emitDeclarationOnly": false + }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.spec.ts", "src/lib/testing/**", "src/testing.ts"] } From 0deb6dff13ba4f01d14a0abea58b723e8af35103 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 10:42:13 -0700 Subject: [PATCH 17/51] refactor(licensing): hoist inferNoncommercial into the licensing lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nine-line `inferNoncommercial()` helper was duplicated verbatim in `provideAgent`, `provideRender`, and `provideChat`. Extract it into `@cacheplane/licensing` and re-export from the public index so all three providers share one implementation. Behavior is unchanged — agent/render/chat specs that cover the nag warn path still pass. --- libs/agent/src/lib/agent.provider.ts | 15 +++---- libs/chat/src/lib/provide-chat.ts | 15 +++---- libs/licensing/src/index.ts | 1 + .../src/lib/infer-noncommercial.spec.ts | 41 +++++++++++++++++++ libs/licensing/src/lib/infer-noncommercial.ts | 22 ++++++++++ libs/render/src/lib/provide-render.ts | 15 +++---- 6 files changed, 79 insertions(+), 30 deletions(-) create mode 100644 libs/licensing/src/lib/infer-noncommercial.spec.ts create mode 100644 libs/licensing/src/lib/infer-noncommercial.ts diff --git a/libs/agent/src/lib/agent.provider.ts b/libs/agent/src/lib/agent.provider.ts index c14d134f0..49fecce8c 100644 --- a/libs/agent/src/lib/agent.provider.ts +++ b/libs/agent/src/lib/agent.provider.ts @@ -1,6 +1,10 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { InjectionToken, Provider } from '@angular/core'; -import { runLicenseCheck, LICENSE_PUBLIC_KEY } from '@cacheplane/licensing'; +import { + runLicenseCheck, + LICENSE_PUBLIC_KEY, + inferNoncommercial, +} from '@cacheplane/licensing'; import { AgentTransport } from './agent.types'; const PACKAGE_NAME = '@cacheplane/angular'; @@ -40,15 +44,6 @@ export interface AgentConfig { export const AGENT_CONFIG = new InjectionToken('AGENT_CONFIG'); -function inferNoncommercial(): boolean { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const proc = (globalThis as any)['process']; - if (proc && proc.env) { - return proc.env['NODE_ENV'] !== 'production'; - } - return false; -} - /** * Angular provider factory that registers global defaults for all * agent instances in the application. diff --git a/libs/chat/src/lib/provide-chat.ts b/libs/chat/src/lib/provide-chat.ts index ac0954881..2c710c734 100644 --- a/libs/chat/src/lib/provide-chat.ts +++ b/libs/chat/src/lib/provide-chat.ts @@ -1,6 +1,10 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; -import { runLicenseCheck, LICENSE_PUBLIC_KEY } from '@cacheplane/licensing'; +import { + runLicenseCheck, + LICENSE_PUBLIC_KEY, + inferNoncommercial, +} from '@cacheplane/licensing'; import type { AngularRegistry } from '@cacheplane/render'; const PACKAGE_NAME = '@cacheplane/chat'; @@ -11,15 +15,6 @@ const PACKAGE_VERSION = : '0.0.0-dev'; const TELEMETRY_ENDPOINT = 'https://telemetry.cacheplane.dev/v1/ping'; -function inferNoncommercial(): boolean { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const proc = (globalThis as any)['process']; - if (proc && proc.env) { - return proc.env['NODE_ENV'] !== 'production'; - } - return false; -} - export interface ChatConfig { /** Default render registry for generative UI components. */ renderRegistry?: AngularRegistry; diff --git a/libs/licensing/src/index.ts b/libs/licensing/src/index.ts index 9ee106e46..551faae06 100644 --- a/libs/licensing/src/index.ts +++ b/libs/licensing/src/index.ts @@ -15,3 +15,4 @@ export { createTelemetryClient } from './lib/telemetry.js'; export type { RunLicenseCheckOptions } from './lib/run-license-check.js'; export { runLicenseCheck } from './lib/run-license-check.js'; export { LICENSE_PUBLIC_KEY } from './lib/license-public-key.js'; +export { inferNoncommercial } from './lib/infer-noncommercial.js'; diff --git a/libs/licensing/src/lib/infer-noncommercial.spec.ts b/libs/licensing/src/lib/infer-noncommercial.spec.ts new file mode 100644 index 000000000..2f9361b8e --- /dev/null +++ b/libs/licensing/src/lib/infer-noncommercial.spec.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import { inferNoncommercial } from './infer-noncommercial.js'; + +describe('inferNoncommercial', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = globalThis as any; + let originalProcess: unknown; + + beforeEach(() => { + originalProcess = g['process']; + }); + + afterEach(() => { + if (typeof originalProcess === 'undefined') { + delete g['process']; + } else { + g['process'] = originalProcess; + } + }); + + it('returns false when process is undefined (browser-like)', () => { + delete g['process']; + expect(inferNoncommercial()).toBe(false); + }); + + it('returns false when NODE_ENV is "production"', () => { + g['process'] = { env: { NODE_ENV: 'production' } }; + expect(inferNoncommercial()).toBe(false); + }); + + it('returns true when NODE_ENV is "development"', () => { + g['process'] = { env: { NODE_ENV: 'development' } }; + expect(inferNoncommercial()).toBe(true); + }); + + it('returns true when NODE_ENV is unset', () => { + g['process'] = { env: {} }; + expect(inferNoncommercial()).toBe(true); + }); +}); diff --git a/libs/licensing/src/lib/infer-noncommercial.ts b/libs/licensing/src/lib/infer-noncommercial.ts new file mode 100644 index 000000000..4155b1dc9 --- /dev/null +++ b/libs/licensing/src/lib/infer-noncommercial.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +/** + * Heuristic default for the `isNoncommercial` flag in `runLicenseCheck`. + * + * Returns `true` when `process.env.NODE_ENV` is anything other than + * `"production"` — treating dev/test/CI builds as noncommercial so the + * license nag stays quiet. Returns `false` when there is no `process` + * global (browser-like environments without a dev shim), which is the + * safe default for production bundles. + * + * Callers can always override via the `isNoncommercial` option on + * `runLicenseCheck`; this is only the fallback. + */ +export function inferNoncommercial(): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = (globalThis as any)['process']; + if (proc && proc.env) { + return proc.env['NODE_ENV'] !== 'production'; + } + return false; +} diff --git a/libs/render/src/lib/provide-render.ts b/libs/render/src/lib/provide-render.ts index 934129f53..17813a79c 100644 --- a/libs/render/src/lib/provide-render.ts +++ b/libs/render/src/lib/provide-render.ts @@ -1,6 +1,10 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; -import { runLicenseCheck, LICENSE_PUBLIC_KEY } from '@cacheplane/licensing'; +import { + runLicenseCheck, + LICENSE_PUBLIC_KEY, + inferNoncommercial, +} from '@cacheplane/licensing'; import type { RenderConfig } from './render.types'; const PACKAGE_NAME = '@cacheplane/render'; @@ -11,15 +15,6 @@ const PACKAGE_VERSION = : '0.0.0-dev'; const TELEMETRY_ENDPOINT = 'https://telemetry.cacheplane.dev/v1/ping'; -function inferNoncommercial(): boolean { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const proc = (globalThis as any)['process']; - if (proc && proc.env) { - return proc.env['NODE_ENV'] !== 'production'; - } - return false; -} - export const RENDER_CONFIG = new InjectionToken('RENDER_CONFIG'); export function provideRender(config: RenderConfig) { From 9c3811ab54fdf2ab0273420682cca46ee4258837 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 10:44:49 -0700 Subject: [PATCH 18/51] chore: clear pre-existing lint errors surfaced by T13 sweep The T13 sanity sweep ran lint across licensing/agent/render/chat and surfaced a pile of pre-existing errors unrelated to the license work. Clear them so the four packages are lint-clean for release. - libs/render: rename stub selectors in views.spec.ts to the `render-` prefix required by @angular-eslint/component-selector. - libs/chat/package.json: declare the missing `@angular/platform-browser` peer dependency (flagged by @nx/dependency-checks). - libs/chat a2ui catalog components: associate labels with their form controls via a per-class id counter (text-field, choice-picker, slider, date-time-input) and make the modal backdrop a proper button role with keyboard handlers (modal.component.ts). --- libs/chat/package.json | 1 + libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts | 6 +++++- libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts | 6 +++++- libs/chat/src/lib/a2ui/catalog/modal.component.ts | 5 +++++ libs/chat/src/lib/a2ui/catalog/slider.component.ts | 6 +++++- libs/chat/src/lib/a2ui/catalog/text-field.component.ts | 6 +++++- libs/render/src/lib/views.spec.ts | 6 +++--- 7 files changed, 29 insertions(+), 7 deletions(-) diff --git a/libs/chat/package.json b/libs/chat/package.json index 34accdb32..e4627a4f7 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -5,6 +5,7 @@ "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", "@angular/forms": "^20.0.0 || ^21.0.0", + "@angular/platform-browser": "^20.0.0 || ^21.0.0", "@cacheplane/licensing": "^0.0.1", "@cacheplane/render": "^0.0.1", "@cacheplane/a2ui": "^0.0.1", diff --git a/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts index 86dbbc3fa..d2413a7b7 100644 --- a/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts @@ -11,8 +11,9 @@ import { emitBinding } from './emit-binding'; changeDetection: ChangeDetectionStrategy.OnPush, template: `
- @if (label()) { } + @if (label()) { } (''); readonly value = input(''); readonly inputType = input<'date' | 'time' | 'datetime-local'>('date'); diff --git a/libs/chat/src/lib/a2ui/catalog/modal.component.ts b/libs/chat/src/lib/a2ui/catalog/modal.component.ts index a71f9db04..c120f855c 100644 --- a/libs/chat/src/lib/a2ui/catalog/modal.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/modal.component.ts @@ -17,7 +17,12 @@ import { emitBinding } from './emit-binding'; >
@if (title()) { diff --git a/libs/chat/src/lib/a2ui/catalog/slider.component.ts b/libs/chat/src/lib/a2ui/catalog/slider.component.ts index 62e9c3122..8a8325048 100644 --- a/libs/chat/src/lib/a2ui/catalog/slider.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/slider.component.ts @@ -12,9 +12,10 @@ import { emitBinding } from './emit-binding'; template: `
@if (label()) { - + } (''); readonly value = input(0); readonly min = input(0); diff --git a/libs/chat/src/lib/a2ui/catalog/text-field.component.ts b/libs/chat/src/lib/a2ui/catalog/text-field.component.ts index e7186693f..2f3c8bfe5 100644 --- a/libs/chat/src/lib/a2ui/catalog/text-field.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/text-field.component.ts @@ -11,8 +11,9 @@ import { emitBinding } from './emit-binding'; changeDetection: ChangeDetectionStrategy.OnPush, template: `
- @if (label()) { } + @if (label()) { } (''); readonly value = input(''); readonly placeholder = input(''); diff --git a/libs/render/src/lib/views.spec.ts b/libs/render/src/lib/views.spec.ts index 695d5fe51..66cbb9ab8 100644 --- a/libs/render/src/lib/views.spec.ts +++ b/libs/render/src/lib/views.spec.ts @@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest'; import { Component } from '@angular/core'; import { views, withViews, withoutViews, toRenderRegistry } from './views'; -@Component({ selector: 'test-a', standalone: true, template: 'A' }) +@Component({ selector: 'render-test-a', standalone: true, template: 'A' }) class CompA {} -@Component({ selector: 'test-b', standalone: true, template: 'B' }) +@Component({ selector: 'render-test-b', standalone: true, template: 'B' }) class CompB {} -@Component({ selector: 'test-c', standalone: true, template: 'C' }) +@Component({ selector: 'render-test-c', standalone: true, template: 'C' }) class CompC {} describe('views()', () => { From ba081e95ca429ed2b7b33e4c509324ea70a75908 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 12:22:58 -0700 Subject: [PATCH 19/51] docs: add minting service design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit License minting service design: Stripe webhook → signed license token → email delivery. Covers architecture, data model, webhook flow, email content, env vars, deployment, manual re-mint CLI, testing strategy, and out-of-scope boundaries. Co-Authored-By: Claude Opus 4 --- .../2026-04-20-minting-service-design.md | 611 ++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-20-minting-service-design.md diff --git a/docs/superpowers/specs/2026-04-20-minting-service-design.md b/docs/superpowers/specs/2026-04-20-minting-service-design.md new file mode 100644 index 000000000..dd32d9723 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-minting-service-design.md @@ -0,0 +1,611 @@ +# Minting Service Design + +**Date:** 2026-04-20 +**Status:** Design approved +**Author:** Brian Love (with Claude) +**Depends on:** `libs/licensing` (`@cacheplane/licensing`) — provides `signLicense` / `verifyLicense` primitives and the published public key. +**Followed by:** Plan 3 — Stripe Checkout + website integration (pricing page, customer-facing purchase flow). + +--- + +## 1. System Overview + +A single Nx app at `apps/minting-service/`, deployed as one Vercel project. + +- `api/stripe-webhook.ts` — the Node serverless function Stripe posts to. +- `api/health.ts` — trivial readiness probe. +- `src/lib/` — pure functions: tier extraction, token signing (wraps `@cacheplane/licensing`'s `signLicense`), email rendering, orchestration. All unit-testable without a live Stripe / DB / Resend. +- Dependencies: `stripe` (official SDK), `resend` (email), `drizzle-orm` (via `@cacheplane/db`), `@cacheplane/licensing` (workspace, for signing), `@cacheplane/db` (workspace, for DB access). + +The service is stateless. Stripe events drive all state changes. Postgres is the source of truth for issued licenses. + +--- + +## 2. Component Decomposition + +### New lib: `libs/db/` (`@cacheplane/db`) + +Shared DB infrastructure so the website can reuse schema + queries later (e.g., when it adds license management UI for authenticated customers). + +``` +libs/db/ + src/ + index.ts — barrel: schema types, client factory, queries + lib/ + schema/ + licenses.ts — Drizzle table def + inferred types + processed-events.ts + index.ts — re-exports all tables + client.ts — createDb(connectionString) → DrizzleClient + queries/ + licenses.ts — upsertLicense, revokeLicense, getLicense, getLicensesByCustomerEmail, updateLicenseToken + processed-events.ts — markEventProcessed, deleteProcessedEvent (compensating) + drizzle.config.ts + drizzle/ — generated migration SQL files (checked in) + project.json — nx targets: build, test, db:generate, db:migrate +``` + +### Minting service: `apps/minting-service/` + +``` +apps/minting-service/ + api/ + stripe-webhook.ts — Stripe webhook entry point (raw-body, signature verify, dispatch) + health.ts — returns { ok: true } + src/ + lib/ + env.ts — validates all env vars at module load; throws with missing-var list + tier.ts — extractTier(priceMetadata), computeSeats(tier, quantity) + sign.ts — mintToken(claims) wraps @cacheplane/licensing's signLicense + email.ts — renderLicenseEmail (pure) + sendLicenseEmail (Resend wrapper) + handlers.ts — handleEvent + handleCheckoutCompleted + handleSubscriptionUpdated + handleSubscriptionDeleted + scripts/ + remint.ts — manual re-mint CLI (see Section 8) + vercel.json + README.md — operator runbook (see Section 8) + package.json + project.json + tsconfig.app.json + tsconfig.spec.json +``` + +**Rationale:** +- API routes stay thin adapters (~30 lines); all logic lives in pure `src/lib/` modules. +- Each `src/lib/*.ts` has one responsibility and a small surface. +- `handlers.ts` is the only place that composes DB + sign + email — everything else is leaves in the tree. +- Schema lives in `@cacheplane/db` because the website will consume the same tables in later plans. + +### Migration runner + +Migrations run from `@cacheplane/db` via `nx run db:migrate`. Operator runs it manually (or from a GitHub Action) against prod DB before deploying the minting service. Serverless + auto-migrate is a known foot-gun; keeping migrations an explicit pre-deploy step is safer. + +--- + +## 3. Data Model + +### `licenses` table + +| Column | Type | Notes | +|---|---|---| +| `id` | `uuid` primary key, default `gen_random_uuid()` | internal ID, also used as `jti` in the signed token | +| `stripe_customer_id` | `text not null` | for lookups by customer | +| `stripe_subscription_id` | `text not null unique` | **the natural key** — UPSERT target | +| `customer_email` | `text not null` | captured at checkout; where license emails go | +| `tier` | `text not null` | `'dev-seat'` or `'app-deployment'` (enforced by app, not DB — easy to extend) | +| `seats` | `integer not null` | dev-seat: Stripe line-item quantity; app-deployment: 1 | +| `issued_at` | `timestamptz not null default now()` | last time we minted/rotated the token for this row | +| `expires_at` | `timestamptz not null` | matches `exp` claim in the signed token | +| `revoked_at` | `timestamptz` | null = active; set on `customer.subscription.deleted` | +| `last_token` | `text not null` | most recently issued signed JWT — enables manual re-mint without re-signing | +| `created_at` | `timestamptz not null default now()` | | +| `updated_at` | `timestamptz not null default now()` | bumped by UPSERT | + +Indexes: `unique(stripe_subscription_id)`, `index(stripe_customer_id)`, `index(customer_email)`. + +### `processed_events` table (idempotency) + +| Column | Type | Notes | +|---|---|---| +| `stripe_event_id` | `text primary key` | Stripe's `evt_...` | +| `event_type` | `text not null` | for debugging/metrics | +| `processed_at` | `timestamptz not null default now()` | | + +Usage: `INSERT ... ON CONFLICT (stripe_event_id) DO NOTHING RETURNING stripe_event_id`. If `RETURNING` produces a row, this is first-time processing. If empty, it's a Stripe retry — return 200 and skip. + +**Retention:** `processed_events` grows unbounded. Not a near-term problem (low webhook volume). Future cleanup cron deferred to a later plan. + +**Key-rotation column:** deliberately omitted. Rotation currently requires library republish (see Q6 in brainstorm). If that changes, add `signing_key_version` via migration. + +--- + +## 4. Webhook Flow + +### Entry point — `api/stripe-webhook.ts` + +```ts +export const config = { api: { bodyParser: false } }; // Stripe needs raw body + +export default async function handler(req, res) { + if (req.method !== 'POST') return res.status(405).end(); + + const rawBody = await readRawBody(req); + const sig = req.headers['stripe-signature']; + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(rawBody, sig, env.STRIPE_WEBHOOK_SECRET); + } catch { + return res.status(400).send('invalid signature'); + } + + try { + await handleEvent(event); + return res.status(200).json({ received: true }); + } catch (err) { + console.error('webhook error', { eventId: event.id, type: event.type, err }); + return res.status(500).send('internal error'); // Stripe will retry + } +} +``` + +### Orchestrator — `handlers.ts` + +```ts +export async function handleEvent(event: Stripe.Event): Promise { + // 1. Idempotency check — first writer wins. + const firstTime = await markEventProcessed(event.id, event.type); + if (!firstTime) return; // Stripe retry, already processed. + + // 2. Dispatch. On error, compensating-delete the event row so Stripe retry can proceed. + try { + switch (event.type) { + case 'checkout.session.completed': + await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session); + break; + case 'customer.subscription.updated': + await handleSubscriptionUpdated(event.data.object as Stripe.Subscription); + break; + case 'customer.subscription.deleted': + await handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + break; + default: + return; // Unknown type — already marked processed, no-op. + } + } catch (err) { + await deleteProcessedEvent(event.id); + throw err; + } +} +``` + +### `handleCheckoutCompleted` + +1. Expand line items: `stripe.checkout.sessions.retrieve(session.id, { expand: ['line_items.data.price'] })`. +2. Extract tier from `line_items[0].price.metadata.cacheplane_tier`. Throw if missing/invalid. +3. Compute `seats` from `line_items[0].quantity` per tier rules (dev-seat: quantity; app-deployment: 1). +4. Pre-generate `id = crypto.randomUUID()` for the row (so we can use it as `jti` in the token). +5. Retrieve the subscription to get `current_period_end` → `expires_at`. +6. `mintToken({ sub: stripe_customer_id, tier, seats, exp: expires_at, jti: id })`. +7. `upsertLicense({ id, stripe_customer_id, stripe_subscription_id, customer_email, tier, seats, issued_at: now, expires_at, last_token })`. +8. `sendLicenseEmail({ to: customer_email, tier, token, expiresAt })`. + +### `handleSubscriptionUpdated` + +Same flow, keyed on `stripe_subscription_id`. **Email is sent only if the token materially changed** — specifically if `tier`, `seats`, or `expires_at` differs from the existing row. Stripe fires `subscription.updated` for many reasons (card change, metadata edit); we don't want to spam customers. + +```ts +const existing = await getLicense(sub.id); +const newClaims = { tier, seats, expires_at }; +if (existing && sameClaims(existing, newClaims)) { + await upsertLicense({ ...existing, ...newClaims, last_token: existing.last_token }); + return; +} +// claims changed → mint + email +const token = mintToken({ ... }); +await upsertLicense({ ..., last_token: token, issued_at: now }); +await sendLicenseEmail({ ... }); +``` + +### `handleSubscriptionDeleted` + +1. `revokeLicense(sub.id)` — sets `revoked_at = now()`. +2. No email. No token rotation. + +### Events explicitly not handled in v1 + +- `invoice.payment_failed` — Stripe's dunning flow handles customer messaging; subscription eventually transitions to `canceled` (which we handle). +- `customer.subscription.paused` / `trialing` — not enabled in v1 product. +- Refunds — manual process. + +### Error taxonomy + +| Error | HTTP | Stripe behavior | Operator action | +|---|---|---|---| +| Invalid signature | 400 | No retry | None (likely bad config or bad actor) | +| Tier missing from price metadata | 500 | Retries for 3 days | Fix `metadata.cacheplane_tier` in Stripe dashboard → retry succeeds | +| DB/Resend transient error | 500 | Retries for 3 days | None — self-heals | +| Resend hard bounce | 200 | No retry | License is stored; use re-mint CLI with `--to=` | + +Logging fails the webhook ack when it should — don't 200 a malformed price, or we silently ship no license. + +--- + +## 5. Email Content + +### Transport + +Resend SDK. `EMAIL_FROM` is a Resend-verified domain (e.g., `licenses@cacheplane.dev` — actual domain registered with Resend as part of setup). + +### `renderLicenseEmail(vars)` — pure function in `email.ts` + +Returns `{ subject, html, text }`. Testable without hitting Resend. + +**Subject:** +``` +Your Cacheplane license — {{tier}} ({{seats}} seat{{s}}) +``` + +**Text body (primary):** +``` +Thanks for subscribing to Cacheplane. + +Your license token is below. Set it as the CACHEPLANE_LICENSE +environment variable in your application: + +-----BEGIN CACHEPLANE LICENSE----- +{{token}} +-----END CACHEPLANE LICENSE----- + +Tier: {{tier}} +Seats: {{seats}} +Expires: {{expiresAtISO}} + +Installation: + export CACHEPLANE_LICENSE="" + +Or in a .env file: + CACHEPLANE_LICENSE= + +Docs: https://cacheplane.dev/docs/licensing +Questions: reply to this email. + +-- The Cacheplane team +``` + +**HTML body:** same content with minimal styling. Token wrapped in `
`. No images, no tracking pixels, no marketing footer — this is transactional credential delivery.
+
+### Design decisions
+
+- **Token inline, not attached / not linked.** Attachments get stripped by corporate mail gateways; download links create a "who else clicked?" problem. Inline text is boring and reliable.
+- **BEGIN/END CACHEPLANE LICENSE delimiters** — mirrors PEM convention; visually obvious what to copy.
+- **`expiresAtISO`** — ISO 8601 UTC (`2027-04-20T00:00:00Z`). Unambiguous across locales.
+- **No "click here to activate"** — token IS the license, no server round-trip.
+- **Subject shows tier + seats** — customers with multiple subscriptions can tell at a glance which arrived.
+
+### Re-mint email
+
+Identical template, same subject. No "this is a resend" banner — customers don't need to know. Support can confirm from `licenses.updated_at` / Resend logs if it matters.
+
+### Error handling
+
+Resend call wrapped in try/catch. Success → log `{ eventId, licenseId, resendId }`. Failure → log + rethrow → webhook returns 500 → compensating `deleteProcessedEvent` (from Section 4) runs so Stripe retry reprocesses.
+
+---
+
+## 6. Environment Variables
+
+Validated in `src/lib/env.ts` at module load.
+
+| Var | Required | Purpose | Format |
+|---|---|---|---|
+| `STRIPE_SECRET_KEY` | yes | Stripe API calls (expanding line items) | `sk_live_...` / `sk_test_...` |
+| `STRIPE_WEBHOOK_SECRET` | yes | Verify webhook signatures | `whsec_...` |
+| `DATABASE_URL` | yes | Vercel Postgres connection string | `postgres://...?sslmode=require` |
+| `RESEND_API_KEY` | yes | Send license emails | `re_...` |
+| `EMAIL_FROM` | yes | `From:` header on license emails | `licenses@cacheplane.dev` |
+| `LICENSE_SIGNING_PRIVATE_KEY_HEX` | yes | Ed25519 private key for `signLicense` | 64-char hex (32 bytes) |
+| `LICENSE_DEFAULT_TTL_DAYS` | no | Fallback `exp` if subscription has no `current_period_end` | integer, defaults to `365` |
+
+### `env.ts`
+
+```ts
+const REQUIRED = [
+  'STRIPE_SECRET_KEY',
+  'STRIPE_WEBHOOK_SECRET',
+  'DATABASE_URL',
+  'RESEND_API_KEY',
+  'EMAIL_FROM',
+  'LICENSE_SIGNING_PRIVATE_KEY_HEX',
+] as const;
+
+function loadEnv() {
+  const missing = REQUIRED.filter((k) => !process.env[k]);
+  if (missing.length > 0) {
+    throw new Error(`Missing required env vars: ${missing.join(', ')}`);
+  }
+  const keyHex = process.env['LICENSE_SIGNING_PRIVATE_KEY_HEX']!;
+  if (!/^[0-9a-f]{64}$/i.test(keyHex)) {
+    throw new Error('LICENSE_SIGNING_PRIVATE_KEY_HEX must be 64 hex chars (32 bytes)');
+  }
+  return {
+    STRIPE_SECRET_KEY: process.env['STRIPE_SECRET_KEY']!,
+    STRIPE_WEBHOOK_SECRET: process.env['STRIPE_WEBHOOK_SECRET']!,
+    DATABASE_URL: process.env['DATABASE_URL']!,
+    RESEND_API_KEY: process.env['RESEND_API_KEY']!,
+    EMAIL_FROM: process.env['EMAIL_FROM']!,
+    LICENSE_SIGNING_PRIVATE_KEY_HEX: keyHex,
+    LICENSE_DEFAULT_TTL_DAYS: Number(process.env['LICENSE_DEFAULT_TTL_DAYS'] ?? 365),
+  };
+}
+
+export const env = loadEnv();
+```
+
+### Vercel env scoping
+
+- **Production:** live Stripe keys (`sk_live_`, webhook secret from live endpoint), prod DB, prod Resend key, prod signing key.
+- **Preview:** test Stripe keys (`sk_test_`, webhook secret from test endpoint), separate preview DB, test Resend audience, **a distinct preview signing key** (a leaked preview key must not mint valid prod licenses).
+- **Development (local `.env`):** same shape as Preview. `.env` gitignored. `.env.example` in app root lists every var name with a placeholder.
+
+### Secret handling
+
+- `LICENSE_SIGNING_PRIVATE_KEY_HEX` is the crown jewel. Generate once, store in Vercel env as "Sensitive" (write-only), back up to password manager.
+- `STRIPE_WEBHOOK_SECRET` differs between test-mode and live-mode endpoints — don't cross-wire.
+- Key-generation one-liner (documented in `libs/licensing/README.md`):
+  ```
+  node -e "import('@noble/ed25519').then(e => { const sk = e.utils.randomPrivateKey(); console.log('priv:', Buffer.from(sk).toString('hex')); e.getPublicKey(sk).then(pk => console.log('pub:', Buffer.from(pk).toString('hex'))); })"
+  ```
+
+---
+
+## 7. Deployment & Monorepo Integration
+
+### Nx app setup
+
+- Generator: `nx g @nx/node:app minting-service`.
+- `project.json` targets:
+  - `build` — `@nx/js:tsc` producing `dist/apps/minting-service/`.
+  - `test` — `@nx/jest:jest`.
+  - `lint` — `@nx/eslint:lint`.
+  - `serve` — local dev via `vercel dev` (not `nx serve`).
+  - `remint` — manual re-mint CLI (see Section 8).
+
+### TypeScript config
+
+`tsconfig.app.json`:
+- `module: NodeNext`, `moduleResolution: NodeNext`, `target: ES2022`.
+- `.js` extensions in relative imports (same constraint as `@cacheplane/licensing` — required for Node ESM on Vercel).
+
+### Vercel project
+
+- One Vercel project rooted at `apps/minting-service/`.
+- `apps/minting-service/vercel.json`:
+  ```json
+  {
+    "buildCommand": "cd ../.. && npx nx build minting-service",
+    "outputDirectory": "../../dist/apps/minting-service",
+    "installCommand": "cd ../.. && pnpm install --frozen-lockfile",
+    "framework": null,
+    "functions": {
+      "api/*.ts": {
+        "runtime": "nodejs20.x",
+        "maxDuration": 10
+      }
+    }
+  }
+  ```
+- Vercel project root in dashboard set to `apps/minting-service`.
+- Git-integration deploys from `main`. Preview deploys from every PR branch (using test env vars per Section 6).
+
+### Local development
+
+- `vercel dev` from `apps/minting-service/` — emulates serverless runtime, picks up `api/*.ts`, loads `.env`.
+- Stripe webhook → local: `stripe listen --forward-to localhost:3000/api/stripe-webhook` (prints a local webhook secret for `.env`).
+- Local Postgres: `docker run -p 5432:5432 postgres:16`.
+
+### Workspace wiring
+
+- `apps/minting-service/package.json` declares:
+  - `"@cacheplane/licensing": "workspace:*"`
+  - `"@cacheplane/db": "workspace:*"`
+- Nx resolves to `dist/libs/...` at build. pnpm workspace resolution handles the Vercel install step.
+- `nx.json` `targetDefaults.build.dependsOn: ["^build"]` ensures libs build before the app.
+
+### CI
+
+- Existing monorepo CI (`nx affected -t lint test build`) covers the service automatically.
+- Migrations: run `DATABASE_URL= nx run db:migrate` from operator's laptop or a GitHub Action *before* `vercel deploy` for schema changes. Not in v1 CI automation.
+
+### File map recap
+
+| Path | Purpose |
+|---|---|
+| `libs/licensing/` | **Existing.** Signing & verification primitives. |
+| `libs/db/` | **New.** Drizzle schema, migrations, client, domain queries. |
+| `apps/minting-service/` | **New.** Vercel serverless app. Webhook + handlers + email + re-mint CLI. |
+
+---
+
+## 8. Manual Re-mint CLI
+
+### Why it exists
+
+- Customer's license email got lost / spam-filtered / deleted.
+- Resend hard-bounced the original send; operator retries after fixing the address.
+- Support needs to hand a specific license token to a customer over a support channel.
+
+None of these justify a customer-facing "resend" button in v1 (that's Plan 3+ territory — needs auth and a web UI). A CLI run by the operator against prod DB is the minimum viable operational tool.
+
+### Invocation
+
+```
+nx run minting-service:remint --sub=sub_1234 [--to=] [--dry-run] [--new-token]
+```
+
+Or directly:
+```
+DATABASE_URL= RESEND_API_KEY= EMAIL_FROM= tsx apps/minting-service/scripts/remint.ts --sub=sub_1234
+```
+
+### `project.json` target
+
+```json
+"remint": {
+  "executor": "nx:run-commands",
+  "options": {
+    "command": "tsx apps/minting-service/scripts/remint.ts",
+    "cwd": "{projectRoot}"
+  }
+}
+```
+
+### Flags
+
+| Flag | Required | Purpose |
+|---|---|---|
+| `--sub=` | yes | Which license to re-send |
+| `--to=` | no | Override destination email. Defaults to `licenses.customer_email`. |
+| `--dry-run` | no | Print what would be sent; don't call Resend |
+| `--new-token` | no | Re-sign a fresh token (updates `last_token` + `issued_at`). Default: re-send existing `last_token`. |
+
+### Logic
+
+```ts
+const args = parseArgs();
+const license = await getLicense(args.sub);
+if (!license) die(`No license found for subscription ${args.sub}`);
+if (license.revoked_at) die(`License is revoked (revoked_at=${license.revoked_at}). Refusing to resend.`);
+
+let token = license.last_token;
+if (args.newToken) {
+  token = mintToken({
+    sub: license.stripe_customer_id,
+    tier: license.tier,
+    seats: license.seats,
+    exp: license.expires_at,
+    jti: license.id,
+  });
+  await updateLicenseToken(license.id, token); // bumps last_token + issued_at
+}
+
+const to = args.to ?? license.customer_email;
+
+if (args.dryRun) {
+  console.log(renderLicenseEmail({ to, tier: license.tier, token, expiresAt: license.expires_at }));
+  return;
+}
+
+await sendLicenseEmail({ to, tier: license.tier, token, expiresAt: license.expires_at });
+console.log(`Sent to ${to} for subscription ${args.sub}`);
+```
+
+### Safety rails
+
+- **Refuses to resend revoked licenses** — `revoked_at IS NOT NULL` is a hard stop. Operator must flip it in SQL explicitly if they really mean to.
+- **Logs to stdout, not to DB.** Reuses Resend's delivery log + `licenses.updated_at` (bumped only if `--new-token`). Keeping the CLI stateless makes it reversible.
+- **`--dry-run` is the default recommendation in the README.** Operator previews before sending.
+- **Reuses `renderLicenseEmail` + `sendLicenseEmail`** from `src/lib/email.ts` — zero duplication.
+
+### What this CLI is not
+
+- Not a way to mint licenses for non-paying customers — requires an existing `licenses` row.
+- Not a way to change tier / seats / expiry — those come from Stripe.
+- Not a customer portal — Plan 3+.
+
+### Operator documentation
+
+`apps/minting-service/README.md` is a public-repo operator runbook. Public repo is fine because the threat model is: **possession of the private key is the only thing that matters.** Everything else (schema, webhook logic, re-mint flow) is just plumbing. Contents:
+
+1. **Overview** — what this service does, what it doesn't do.
+2. **Architecture** — pointer to this design spec.
+3. **Local development** — `vercel dev` + `stripe listen` + local Postgres.
+4. **Environment variables** — full list from Section 6; key-generation one-liner.
+5. **Deployment** — Vercel project config, how to run migrations before deploy.
+6. **Operator runbook:**
+   - **Re-mint a license:** `nx run minting-service:remint --sub=sub_xxx [--dry-run] [--to=new@email.com] [--new-token]` — flag-by-flag examples.
+   - **Look up a customer's license:** `psql $DATABASE_URL -c "SELECT * FROM licenses WHERE customer_email = 'x@y.z'"`.
+   - **Manually revoke:** `UPDATE licenses SET revoked_at = now() WHERE stripe_subscription_id = 'sub_xxx'`. Warning: prefer canceling the Stripe subscription instead — this bypasses the normal flow.
+   - **Un-revoke after accidental revoke:** `UPDATE licenses SET revoked_at = NULL WHERE ...` then run `remint --new-token`.
+   - **Handle a failed webhook:** check Stripe dashboard event log → find `evt_xxx` → `SELECT * FROM processed_events WHERE stripe_event_id = 'evt_xxx'`. If present, re-trigger from Stripe dashboard after `DELETE FROM processed_events ...`; if absent, just re-trigger.
+   - **Rotate the signing key:** requires library republish (current Q6 decision). Steps: generate new keypair → update `LICENSE_PUBLIC_KEY` in `libs/licensing` → republish libs → update `LICENSE_SIGNING_PRIVATE_KEY_HEX` in Vercel → batch-run `remint --new-token` over all active licenses.
+7. **Common failure modes** — log-line → meaning → operator action vs. self-healing Stripe retry.
+
+---
+
+## 9. Testing Strategy
+
+### Unit tests (`nx test minting-service`, Jest, no network / no DB)
+
+- `tier.spec.ts` — `extractTier` happy path + throw-on-missing + throw-on-invalid; `computeSeats` for both tiers.
+- `sign.spec.ts` — round-trip: mint a token with a fixture keypair, verify with `@cacheplane/licensing`'s `verifyLicense`. Test claim shape (`sub`, `tier`, `seats`, `exp`, `jti`).
+- `email.spec.ts` — `renderLicenseEmail` returns expected subject/text/html for each tier + seat count. Snapshot-test rendered text body.
+- `handlers.spec.ts` — `handleEvent` with mocked `@cacheplane/db` + mocked `sendLicenseEmail` + mocked `stripe.checkout.sessions.retrieve`:
+  - Idempotency: second call with same `event.id` is a no-op.
+  - `checkout.session.completed` happy path: upserts + emails.
+  - `customer.subscription.updated` with identical claims: upserts (touches `updated_at`) but does NOT email.
+  - `customer.subscription.updated` with changed tier: upserts + emails.
+  - `customer.subscription.deleted`: revokes, no email.
+  - Unknown event type: no-op.
+  - Handler throws → verify compensating `deleteProcessedEvent` is called.
+- `env.spec.ts` — throws on missing vars, throws on bad hex format, loads successfully with all vars present. Uses `jest.isolateModules` per test.
+
+### Integration tests (`nx test minting-service --testPathPattern=integration`, Jest + real Postgres)
+
+- Spin up `postgres:16` via `testcontainers` (Jest global setup/teardown).
+- Run Drizzle migrations against it.
+- Test real query functions from `@cacheplane/db`: `upsertLicense`, `markEventProcessed`, `revokeLicense`, `getLicense`, `updateLicenseToken`, `deleteProcessedEvent`.
+- Test `handleEvent` end-to-end with real DB + mocked Stripe SDK + mocked Resend. Verifies idempotency actually works at the DB level (not just mocked).
+
+### Manual smoke test (documented in README, run once per deploy to preview)
+
+1. `stripe trigger checkout.session.completed` against preview webhook URL.
+2. Verify row: `SELECT * FROM licenses ORDER BY created_at DESC LIMIT 1`.
+3. Verify email arrives at a test address (Resend test mode).
+4. Copy token → paste into a test app with `CACHEPLANE_LICENSE=` → verify `runLicenseCheck` reports `active`.
+5. `stripe trigger customer.subscription.deleted` → verify `revoked_at` is set.
+6. `nx run minting-service:remint --sub= --dry-run` → verify it refuses (revoked).
+
+### Not tested
+
+- Stripe's own webhook delivery / signature logic — trust the SDK.
+- Resend's email internals — trust their API.
+- End-to-end against live Stripe in CI — too flaky, too expensive. Preview manual smoke covers it.
+
+---
+
+## 10. Out of Scope (deferred to later plans)
+
+### Customer-facing features
+- Customer portal / license dashboard (viewing, downloading, resending from a web UI). Requires auth, which pulls in user accounts.
+- Self-service "resend my license email" flow.
+- In-app license management (upgrading tier, adding seats) outside Stripe's own customer portal.
+- Showing license status on the marketing site.
+
+### Sales / checkout
+- Stripe Checkout integration on the website (`cacheplane.dev/pricing` → checkout). **This is Plan 3.**
+- Free trials, coupons, custom pricing — handled entirely in Stripe, outside this service.
+- Annual vs. monthly toggle — Stripe price config, no service changes.
+
+### Licensing model extensions
+- Offline license files (signed JSON as downloaded file instead of email token). Schema supports it.
+- Per-environment licenses (dev/staging/prod).
+- License usage telemetry (reporting seat usage to a server). `@cacheplane/licensing` has telemetry hooks but no receiving endpoint. Separate plan.
+- Floating / concurrent-user licenses — current design is seat-count.
+
+### Operations
+- `processed_events` retention cron.
+- Automated migration runs in CI (manual for v1).
+- Alerting on webhook failures (Vercel log drains → Sentry/Datadog).
+- Admin UI for browsing licenses (use `psql` for v1).
+- Automated signing-key rotation — requires library republish or a `kid` field for multi-key verification. Deliberately punted.
+
+### Stripe event coverage
+- `invoice.payment_failed` — rely on Stripe dunning + eventual `customer.subscription.deleted`.
+- `customer.subscription.paused`, `trialing` — not enabled in v1 product.
+- Refunds — manual.
+- Metered/usage-based pricing — product is flat subscription for v1.
+
+### Security hardening
+- Signing key stored in Vercel env var, not a KMS (AWS KMS / GCP KMS). Later plan if threat model justifies it.
+- No rate limiting on the webhook beyond Vercel defaults — Stripe is the only caller.
+- No audit log of re-mint invocations. A `remint_log` table is a later addition if needed for compliance.

From 7e41fce09bf64c0e5f0a117ee605a94ab4d2bd57 Mon Sep 17 00:00:00 2001
From: Brian Love 
Date: Mon, 20 Apr 2026 12:25:09 -0700
Subject: [PATCH 20/51] docs: align minting spec tier values with shipped
 LicenseTier type

Spec used 'dev-seat' but @cacheplane/licensing's LicenseTier is 'developer-seat'.
Corrected to match the library's existing type.

Co-Authored-By: Claude Opus 4 
---
 docs/superpowers/specs/2026-04-20-minting-service-design.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/docs/superpowers/specs/2026-04-20-minting-service-design.md b/docs/superpowers/specs/2026-04-20-minting-service-design.md
index dd32d9723..6299fed7a 100644
--- a/docs/superpowers/specs/2026-04-20-minting-service-design.md
+++ b/docs/superpowers/specs/2026-04-20-minting-service-design.md
@@ -91,8 +91,8 @@ Migrations run from `@cacheplane/db` via `nx run db:migrate`. Operator runs it m
 | `stripe_customer_id` | `text not null` | for lookups by customer |
 | `stripe_subscription_id` | `text not null unique` | **the natural key** — UPSERT target |
 | `customer_email` | `text not null` | captured at checkout; where license emails go |
-| `tier` | `text not null` | `'dev-seat'` or `'app-deployment'` (enforced by app, not DB — easy to extend) |
-| `seats` | `integer not null` | dev-seat: Stripe line-item quantity; app-deployment: 1 |
+| `tier` | `text not null` | `'developer-seat'` or `'app-deployment'` (enforced by app, not DB — easy to extend; matches `LicenseTier` in `@cacheplane/licensing`) |
+| `seats` | `integer not null` | developer-seat: Stripe line-item quantity; app-deployment: 1 |
 | `issued_at` | `timestamptz not null default now()` | last time we minted/rotated the token for this row |
 | `expires_at` | `timestamptz not null` | matches `exp` claim in the signed token |
 | `revoked_at` | `timestamptz` | null = active; set on `customer.subscription.deleted` |
@@ -182,7 +182,7 @@ export async function handleEvent(event: Stripe.Event): Promise {
 
 1. Expand line items: `stripe.checkout.sessions.retrieve(session.id, { expand: ['line_items.data.price'] })`.
 2. Extract tier from `line_items[0].price.metadata.cacheplane_tier`. Throw if missing/invalid.
-3. Compute `seats` from `line_items[0].quantity` per tier rules (dev-seat: quantity; app-deployment: 1).
+3. Compute `seats` from `line_items[0].quantity` per tier rules (developer-seat: quantity; app-deployment: 1).
 4. Pre-generate `id = crypto.randomUUID()` for the row (so we can use it as `jti` in the token).
 5. Retrieve the subscription to get `current_period_end` → `expires_at`.
 6. `mintToken({ sub: stripe_customer_id, tier, seats, exp: expires_at, jti: id })`.

From ac5ef08cbd82970dc20f3b43d72d2bbb688c0385 Mon Sep 17 00:00:00 2001
From: Brian Love 
Date: Mon, 20 Apr 2026 12:42:26 -0700
Subject: [PATCH 21/51] docs: add minting service implementation plan

26-task plan across 9 phases: extend @cacheplane/licensing with signLicense;
scaffold @cacheplane/db lib with Drizzle schema + migrations + queries;
scaffold apps/minting-service with pure modules for env/tier/sign/email;
handlers with idempotency + compensating-delete + material-change check;
webhook endpoint; manual re-mint CLI; operator runbook. TDD throughout.

Co-Authored-By: Claude Opus 4 
---
 .../plans/2026-04-20-minting-service.md       | 3162 +++++++++++++++++
 1 file changed, 3162 insertions(+)
 create mode 100644 docs/superpowers/plans/2026-04-20-minting-service.md

diff --git a/docs/superpowers/plans/2026-04-20-minting-service.md b/docs/superpowers/plans/2026-04-20-minting-service.md
new file mode 100644
index 000000000..3d1c8818a
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-20-minting-service.md
@@ -0,0 +1,3162 @@
+# Minting Service Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a Vercel serverless service that receives Stripe webhooks, mints signed Ed25519 license tokens, persists them to Postgres, and emails them to customers — plus a manual re-mint CLI.
+
+**Architecture:** Stripe webhook → signature verify → idempotency check via `processed_events` table → dispatch by event type → tier extraction from price metadata → token signing via `@cacheplane/licensing` → UPSERT into `licenses` table → email via Resend. All orchestration lives in pure functions under `apps/minting-service/src/lib/`; schema and queries live in a shared `@cacheplane/db` lib so the website can reuse them later.
+
+**Tech Stack:** TypeScript (NodeNext ESM), Nx monorepo, Drizzle ORM, Vercel Postgres, Stripe Node SDK, Resend SDK, `@noble/ed25519` (via `@cacheplane/licensing`), Vercel Node Serverless runtime, Jest, testcontainers (for integration tests).
+
+**Spec:** `docs/superpowers/specs/2026-04-20-minting-service-design.md`
+
+---
+
+## File Structure
+
+**Added to existing `libs/licensing/`:**
+- `src/lib/sign-license.ts` — new `signLicense()` function wrapping `@noble/ed25519`
+- `src/lib/sign-license.spec.ts`
+- `src/index.ts` — export `signLicense`
+
+**New lib `libs/db/` (`@cacheplane/db`):**
+- `src/index.ts` — barrel
+- `src/lib/client.ts` — `createDb(connectionString)` client factory
+- `src/lib/schema/licenses.ts` — Drizzle table + types
+- `src/lib/schema/processed-events.ts`
+- `src/lib/schema/index.ts`
+- `src/lib/queries/licenses.ts` — `upsertLicense`, `revokeLicense`, `getLicense`, `getLicensesByCustomerEmail`, `updateLicenseToken`
+- `src/lib/queries/processed-events.ts` — `markEventProcessed`, `deleteProcessedEvent`
+- `src/lib/queries/licenses.spec.ts`, `src/lib/queries/processed-events.spec.ts` (integration tests — real Postgres)
+- `drizzle.config.ts`
+- `drizzle/0000_init.sql` (generated)
+- `project.json`, `package.json`, `tsconfig.*.json`
+
+**New app `apps/minting-service/`:**
+- `api/stripe-webhook.ts` — webhook entry point
+- `api/health.ts` — health probe
+- `src/lib/env.ts` — env var validation
+- `src/lib/env.spec.ts`
+- `src/lib/tier.ts` — `extractTier`, `computeSeats`
+- `src/lib/tier.spec.ts`
+- `src/lib/sign.ts` — `mintToken` wrapping `@cacheplane/licensing`'s `signLicense`
+- `src/lib/sign.spec.ts`
+- `src/lib/email.ts` — `renderLicenseEmail`, `sendLicenseEmail`
+- `src/lib/email.spec.ts`
+- `src/lib/handlers.ts` — `handleEvent`, `handleCheckoutCompleted`, `handleSubscriptionUpdated`, `handleSubscriptionDeleted`
+- `src/lib/handlers.spec.ts`
+- `src/lib/stripe.ts` — shared `Stripe` SDK singleton
+- `scripts/remint.ts` — manual re-mint CLI
+- `scripts/remint.spec.ts`
+- `vercel.json`
+- `.env.example`
+- `README.md` — operator runbook
+- `project.json`, `package.json`, `tsconfig.*.json`
+
+---
+
+## Phase A: Extend `@cacheplane/licensing` with signing
+
+### Task 1: Add `signLicense()` to `@cacheplane/licensing`
+
+**Files:**
+- Create: `libs/licensing/src/lib/sign-license.ts`
+- Create: `libs/licensing/src/lib/sign-license.spec.ts`
+- Modify: `libs/licensing/src/index.ts` (add export)
+
+**Context:** `@cacheplane/licensing` already ships `verifyLicense` (Ed25519 signature verify) and `parseLicenseToken`. The token format is `base64url(JSON-payload).base64url(ed25519-sig)`. We need a complementary `signLicense` so the minting service can produce tokens. Claims shape is already defined in `libs/licensing/src/lib/license-token.ts`:
+
+```ts
+export interface LicenseClaims {
+  sub: string;        // Stripe customer id
+  tier: LicenseTier;  // 'developer-seat' | 'app-deployment' | 'enterprise'
+  iat: number;        // epoch seconds
+  exp: number;        // epoch seconds
+  seats: number;      // >= 1
+}
+```
+
+- [ ] **Step 1: Write the failing test**
+
+Create `libs/licensing/src/lib/sign-license.spec.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import * as ed from '@noble/ed25519';
+import { signLicense } from './sign-license.js';
+import { verifyLicense } from './verify-license.js';
+import type { LicenseClaims } from './license-token.js';
+
+describe('signLicense', () => {
+  it('produces a token that verifyLicense accepts with the matching public key', async () => {
+    const privateKey = ed.utils.randomPrivateKey();
+    const publicKey = await ed.getPublicKeyAsync(privateKey);
+    const claims: LicenseClaims = {
+      sub: 'cus_test_123',
+      tier: 'developer-seat',
+      iat: 1_700_000_000,
+      exp: 1_800_000_000,
+      seats: 5,
+    };
+
+    const token = await signLicense(claims, privateKey);
+    const result = await verifyLicense(token, publicKey);
+
+    expect(result.ok).toBe(true);
+    if (result.ok) {
+      expect(result.claims).toEqual(claims);
+    }
+  });
+
+  it('produces a token with two base64url segments separated by a dot', async () => {
+    const privateKey = ed.utils.randomPrivateKey();
+    const claims: LicenseClaims = {
+      sub: 'cus_abc',
+      tier: 'app-deployment',
+      iat: 1_700_000_000,
+      exp: 1_800_000_000,
+      seats: 1,
+    };
+
+    const token = await signLicense(claims, privateKey);
+    const parts = token.split('.');
+
+    expect(parts).toHaveLength(2);
+    expect(parts[0]).toMatch(/^[A-Za-z0-9_-]+$/);
+    expect(parts[1]).toMatch(/^[A-Za-z0-9_-]+$/);
+  });
+
+  it('tokens signed with different keys fail verification against the wrong key', async () => {
+    const sk1 = ed.utils.randomPrivateKey();
+    const sk2 = ed.utils.randomPrivateKey();
+    const pk2 = await ed.getPublicKeyAsync(sk2);
+    const claims: LicenseClaims = {
+      sub: 'cus_x',
+      tier: 'developer-seat',
+      iat: 1_700_000_000,
+      exp: 1_800_000_000,
+      seats: 1,
+    };
+
+    const token = await signLicense(claims, sk1);
+    const result = await verifyLicense(token, pk2);
+
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.reason).toBe('tampered');
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd /tmp/aaf-licensing && npx nx test licensing --testPathPattern=sign-license`
+Expected: FAIL with "Cannot find module './sign-license.js'"
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `libs/licensing/src/lib/sign-license.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import * as ed from '@noble/ed25519';
+import type { LicenseClaims } from './license-token.js';
+
+function bytesToBase64Url(bytes: Uint8Array): string {
+  return Buffer.from(bytes)
+    .toString('base64')
+    .replace(/\+/g, '-')
+    .replace(/\//g, '_')
+    .replace(/=+$/, '');
+}
+
+/**
+ * Sign license claims with an Ed25519 private key.
+ * Returns a compact token of the form `.`,
+ * compatible with {@link parseLicenseToken} and {@link verifyLicense}.
+ */
+export async function signLicense(
+  claims: LicenseClaims,
+  privateKey: Uint8Array,
+): Promise {
+  const payloadJson = JSON.stringify(claims);
+  const payloadBytes = new TextEncoder().encode(payloadJson);
+  const signature = await ed.signAsync(payloadBytes, privateKey);
+  return `${bytesToBase64Url(payloadBytes)}.${bytesToBase64Url(signature)}`;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd /tmp/aaf-licensing && npx nx test licensing --testPathPattern=sign-license`
+Expected: PASS (3 tests)
+
+- [ ] **Step 5: Export from barrel**
+
+Modify `libs/licensing/src/index.ts` — add after the existing `runLicenseCheck` export:
+
+```ts
+export { signLicense } from './lib/sign-license.js';
+```
+
+- [ ] **Step 6: Verify build still works**
+
+Run: `cd /tmp/aaf-licensing && npx nx build licensing`
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add libs/licensing/src/lib/sign-license.ts libs/licensing/src/lib/sign-license.spec.ts libs/licensing/src/index.ts
+git commit -m "feat(licensing): add signLicense for minting signed license tokens"
+```
+
+---
+
+## Phase B: `@cacheplane/db` library
+
+### Task 2: Scaffold `@cacheplane/db` lib
+
+**Files:**
+- Create: `libs/db/project.json`
+- Create: `libs/db/package.json`
+- Create: `libs/db/tsconfig.json`
+- Create: `libs/db/tsconfig.lib.json`
+- Create: `libs/db/tsconfig.spec.json`
+- Create: `libs/db/src/index.ts` (placeholder)
+- Create: `libs/db/jest.config.ts`
+- Create: `libs/db/eslint.config.mjs`
+- Modify: `tsconfig.base.json` (add path alias)
+
+**Context:** Follow the exact conventions of `libs/licensing` — that lib is a working reference for an `@nx/js:tsc`-built ESM Node lib in this monorepo. The key constraints established by prior licensing work:
+- `module: NodeNext`, `moduleResolution: NodeNext` in `tsconfig.lib.json`
+- `emitDeclarationOnly: false` must be set explicitly (tsconfig.base.json defaults it to true)
+- Relative imports inside `src/` use `.js` extensions (required by Node ESM at runtime)
+
+- [ ] **Step 1: Scaffold via Nx generator (non-interactive)**
+
+Run:
+```bash
+cd /tmp/aaf-licensing
+npx nx g @nx/js:lib libs/db --name=db --importPath=@cacheplane/db --bundler=tsc --linter=eslint --unitTestRunner=jest --no-interactive
+```
+
+Expected: creates `libs/db/` with scaffolded files.
+
+- [ ] **Step 2: Verify TSConfig matches licensing's ESM setup**
+
+Read `libs/licensing/tsconfig.lib.json`. Apply the same `compilerOptions` to `libs/db/tsconfig.lib.json`. Specifically ensure it contains:
+
+```json
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "outDir": "../../dist/out-tsc",
+    "declaration": true,
+    "emitDeclarationOnly": false
+  },
+  "include": ["src/**/*.ts"],
+  "exclude": ["src/**/*.spec.ts", "jest.config.ts"]
+}
+```
+
+- [ ] **Step 3: Verify `tsconfig.json` uses NodeNext**
+
+Ensure `libs/db/tsconfig.json` has `module` and `moduleResolution` both set to `NodeNext` (matching `libs/licensing/tsconfig.json`). If the generator emitted different values, edit to match.
+
+- [ ] **Step 4: Replace placeholder barrel**
+
+Overwrite `libs/db/src/index.ts` with:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+// Barrel populated in later tasks.
+export {};
+```
+
+- [ ] **Step 5: Verify path alias was added**
+
+Open `tsconfig.base.json`. Confirm it contains an entry like:
+```json
+"@cacheplane/db": ["libs/db/src/index.ts"]
+```
+If missing, add it under `compilerOptions.paths`.
+
+- [ ] **Step 6: Verify the lib builds, tests, and lints clean**
+
+Run:
+```bash
+cd /tmp/aaf-licensing
+npx nx build db && npx nx test db --passWithNoTests && npx nx lint db
+```
+Expected: all three PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add libs/db tsconfig.base.json
+git commit -m "feat(db): scaffold @cacheplane/db lib"
+```
+
+---
+
+### Task 3: Add Drizzle dependencies and client factory
+
+**Files:**
+- Modify: `package.json` (add `drizzle-orm`, `postgres`, `drizzle-kit`)
+- Modify: `libs/db/package.json` (add peer deps)
+- Create: `libs/db/src/lib/client.ts`
+- Create: `libs/db/src/lib/client.spec.ts`
+
+**Context:** We're using the `postgres` driver (not `pg`) with Drizzle. Rationale: `postgres` has first-class ESM support, a smaller dependency footprint, and is the recommended driver in Drizzle docs for both Node and edge. Vercel Postgres' connection strings work identically with either driver.
+
+- [ ] **Step 1: Add runtime and dev dependencies**
+
+Run:
+```bash
+cd /tmp/aaf-licensing
+pnpm add drizzle-orm postgres
+pnpm add -D drizzle-kit
+```
+
+- [ ] **Step 2: Write the failing test for client factory**
+
+Create `libs/db/src/lib/client.spec.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { createDb } from './client.js';
+
+describe('createDb', () => {
+  it('returns an object with a query builder and a connection closer', () => {
+    const db = createDb('postgres://fake@localhost:5432/fake');
+    expect(db).toBeDefined();
+    expect(typeof db.close).toBe('function');
+    // Close immediately — we're not actually connecting.
+    return db.close();
+  });
+
+  it('throws if the connection string is empty', () => {
+    expect(() => createDb('')).toThrow(/connection string/i);
+  });
+});
+```
+
+- [ ] **Step 3: Run to verify failure**
+
+Run: `cd /tmp/aaf-licensing && npx nx test db --testPathPattern=client`
+Expected: FAIL with "Cannot find module './client.js'"
+
+- [ ] **Step 4: Implement the client factory**
+
+Create `libs/db/src/lib/client.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import * as schema from './schema/index.js';
+
+export type Db = ReturnType> & {
+  close: () => Promise;
+};
+
+/**
+ * Create a Drizzle client bound to the given Postgres connection string.
+ * Caller is responsible for calling `close()` during shutdown.
+ */
+export function createDb(connectionString: string): Db {
+  if (!connectionString) {
+    throw new Error('createDb: connection string is required');
+  }
+  const sql = postgres(connectionString, { prepare: false });
+  const db = drizzle(sql, { schema }) as Db;
+  db.close = () => sql.end();
+  return db;
+}
+```
+
+- [ ] **Step 5: Create schema barrel stub**
+
+Create `libs/db/src/lib/schema/index.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+// Populated in Tasks 4 & 5.
+export {};
+```
+
+- [ ] **Step 6: Run tests — should pass**
+
+Run: `cd /tmp/aaf-licensing && npx nx test db --testPathPattern=client`
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add package.json pnpm-lock.yaml libs/db/src/lib/client.ts libs/db/src/lib/client.spec.ts libs/db/src/lib/schema/index.ts libs/db/package.json
+git commit -m "feat(db): add Drizzle client factory"
+```
+
+---
+
+### Task 4: Define `licenses` table schema
+
+**Files:**
+- Create: `libs/db/src/lib/schema/licenses.ts`
+- Modify: `libs/db/src/lib/schema/index.ts` (export)
+
+- [ ] **Step 1: Implement schema**
+
+Create `libs/db/src/lib/schema/licenses.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { pgTable, uuid, text, integer, timestamp, index } from 'drizzle-orm/pg-core';
+import { sql } from 'drizzle-orm';
+
+export const licenses = pgTable(
+  'licenses',
+  {
+    id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
+    stripeCustomerId: text('stripe_customer_id').notNull(),
+    stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(),
+    customerEmail: text('customer_email').notNull(),
+    tier: text('tier').notNull(),
+    seats: integer('seats').notNull(),
+    issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(),
+    expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
+    revokedAt: timestamp('revoked_at', { withTimezone: true }),
+    lastToken: text('last_token').notNull(),
+    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+  },
+  (t) => ({
+    customerIdx: index('licenses_customer_idx').on(t.stripeCustomerId),
+    emailIdx: index('licenses_email_idx').on(t.customerEmail),
+  }),
+);
+
+export type License = typeof licenses.$inferSelect;
+export type NewLicense = typeof licenses.$inferInsert;
+```
+
+- [ ] **Step 2: Export from schema barrel**
+
+Replace `libs/db/src/lib/schema/index.ts` contents:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+export * from './licenses.js';
+```
+
+- [ ] **Step 3: Verify build**
+
+Run: `cd /tmp/aaf-licensing && npx nx build db`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add libs/db/src/lib/schema/
+git commit -m "feat(db): add licenses table schema"
+```
+
+---
+
+### Task 5: Define `processed_events` table schema
+
+**Files:**
+- Create: `libs/db/src/lib/schema/processed-events.ts`
+- Modify: `libs/db/src/lib/schema/index.ts` (export)
+
+- [ ] **Step 1: Implement schema**
+
+Create `libs/db/src/lib/schema/processed-events.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
+
+export const processedEvents = pgTable('processed_events', {
+  stripeEventId: text('stripe_event_id').primaryKey(),
+  eventType: text('event_type').notNull(),
+  processedAt: timestamp('processed_at', { withTimezone: true }).notNull().defaultNow(),
+});
+
+export type ProcessedEvent = typeof processedEvents.$inferSelect;
+export type NewProcessedEvent = typeof processedEvents.$inferInsert;
+```
+
+- [ ] **Step 2: Export from barrel**
+
+Modify `libs/db/src/lib/schema/index.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+export * from './licenses.js';
+export * from './processed-events.js';
+```
+
+- [ ] **Step 3: Verify build**
+
+Run: `cd /tmp/aaf-licensing && npx nx build db`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add libs/db/src/lib/schema/
+git commit -m "feat(db): add processed_events table schema"
+```
+
+---
+
+### Task 6: Configure drizzle-kit and generate initial migration
+
+**Files:**
+- Create: `libs/db/drizzle.config.ts`
+- Create: `libs/db/drizzle/0000_init.sql` (generated)
+- Modify: `libs/db/project.json` (add `db:generate` and `db:migrate` targets)
+
+- [ ] **Step 1: Create drizzle config**
+
+Create `libs/db/drizzle.config.ts`:
+
+```ts
+import { defineConfig } from 'drizzle-kit';
+
+export default defineConfig({
+  schema: './src/lib/schema/index.ts',
+  out: './drizzle',
+  dialect: 'postgresql',
+  dbCredentials: {
+    url: process.env['DATABASE_URL'] ?? '',
+  },
+});
+```
+
+- [ ] **Step 2: Add Nx targets**
+
+Edit `libs/db/project.json`. Add these entries under `targets`:
+
+```json
+"db:generate": {
+  "executor": "nx:run-commands",
+  "options": {
+    "command": "drizzle-kit generate",
+    "cwd": "libs/db"
+  }
+},
+"db:migrate": {
+  "executor": "nx:run-commands",
+  "options": {
+    "command": "drizzle-kit migrate",
+    "cwd": "libs/db"
+  }
+}
+```
+
+- [ ] **Step 3: Generate initial migration**
+
+Run:
+```bash
+cd /tmp/aaf-licensing
+npx nx run db:db:generate
+```
+
+This creates `libs/db/drizzle/0000_init.sql` and a metadata file in `libs/db/drizzle/meta/`.
+
+- [ ] **Step 4: Verify migration SQL includes both tables**
+
+Read `libs/db/drizzle/0000_init.sql`. Verify it contains `CREATE TABLE "licenses"` and `CREATE TABLE "processed_events"`. If the file is empty or missing either table, rerun Step 3 after checking that `libs/db/src/lib/schema/index.ts` exports both.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add libs/db/drizzle.config.ts libs/db/project.json libs/db/drizzle/
+git commit -m "feat(db): configure drizzle-kit and generate initial migration"
+```
+
+---
+
+### Task 7: Add testcontainers for integration tests
+
+**Files:**
+- Modify: `package.json` (add `@testcontainers/postgresql`, `testcontainers`)
+- Create: `libs/db/src/lib/queries/test-helpers.ts`
+
+**Context:** Query tests are integration tests (real Postgres). `testcontainers` spins up a disposable Postgres container per test file. This keeps tests hermetic and CI-friendly without requiring a pre-provisioned DB.
+
+- [ ] **Step 1: Install dependencies**
+
+Run:
+```bash
+cd /tmp/aaf-licensing
+pnpm add -D testcontainers @testcontainers/postgresql
+```
+
+- [ ] **Step 2: Create shared test helpers**
+
+Create `libs/db/src/lib/queries/test-helpers.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import { migrate } from 'drizzle-orm/postgres-js/migrator';
+import * as path from 'node:path';
+import * as schema from '../schema/index.js';
+
+export interface TestDb {
+  db: ReturnType>;
+  cleanup: () => Promise;
+}
+
+/**
+ * Spin up a disposable Postgres container, run migrations, and return a
+ * Drizzle client plus a cleanup function. Call `cleanup` in afterAll.
+ */
+export async function startTestDb(): Promise {
+  const container: StartedPostgreSqlContainer = await new PostgreSqlContainer('postgres:16').start();
+  const sql = postgres(container.getConnectionUri(), { prepare: false });
+  const db = drizzle(sql, { schema });
+
+  const migrationsFolder = path.resolve(__dirname, '../../../drizzle');
+  await migrate(db, { migrationsFolder });
+
+  return {
+    db,
+    cleanup: async () => {
+      await sql.end();
+      await container.stop();
+    },
+  };
+}
+```
+
+- [ ] **Step 3: Bump Jest test timeout for integration tests**
+
+Check `libs/db/jest.config.ts`. If `testTimeout` is unset or less than 60000, add:
+```ts
+testTimeout: 60_000,
+```
+(Container startup can take 10-20 seconds on cold Docker.)
+
+- [ ] **Step 4: Verify Docker availability is the only runtime requirement**
+
+No code to run here — just confirm locally that `docker info` works. Document this in the lib README via Task 10.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add package.json pnpm-lock.yaml libs/db/src/lib/queries/test-helpers.ts libs/db/jest.config.ts
+git commit -m "feat(db): add testcontainers-based integration test helpers"
+```
+
+---
+
+### Task 8: Implement `processed-events` queries
+
+**Files:**
+- Create: `libs/db/src/lib/queries/processed-events.ts`
+- Create: `libs/db/src/lib/queries/processed-events.spec.ts`
+- Modify: `libs/db/src/index.ts` (export)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `libs/db/src/lib/queries/processed-events.spec.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { markEventProcessed, deleteProcessedEvent } from './processed-events.js';
+import { startTestDb, type TestDb } from './test-helpers.js';
+
+describe('processed-events queries', () => {
+  let testDb: TestDb;
+
+  beforeAll(async () => {
+    testDb = await startTestDb();
+  });
+
+  afterAll(async () => {
+    await testDb.cleanup();
+  });
+
+  describe('markEventProcessed', () => {
+    it('returns true on first insert of an event id', async () => {
+      const result = await markEventProcessed(testDb.db, 'evt_first', 'checkout.session.completed');
+      expect(result).toBe(true);
+    });
+
+    it('returns false on subsequent inserts of the same event id (idempotent)', async () => {
+      await markEventProcessed(testDb.db, 'evt_dup', 'checkout.session.completed');
+      const result = await markEventProcessed(testDb.db, 'evt_dup', 'checkout.session.completed');
+      expect(result).toBe(false);
+    });
+  });
+
+  describe('deleteProcessedEvent', () => {
+    it('allows an event id to be reprocessed after deletion', async () => {
+      await markEventProcessed(testDb.db, 'evt_retry', 'customer.subscription.updated');
+      await deleteProcessedEvent(testDb.db, 'evt_retry');
+      const result = await markEventProcessed(testDb.db, 'evt_retry', 'customer.subscription.updated');
+      expect(result).toBe(true);
+    });
+
+    it('is a no-op when the event id does not exist', async () => {
+      await expect(deleteProcessedEvent(testDb.db, 'evt_does_not_exist')).resolves.toBeUndefined();
+    });
+  });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd /tmp/aaf-licensing && npx nx test db --testPathPattern=processed-events`
+Expected: FAIL with "Cannot find module './processed-events.js'"
+
+- [ ] **Step 3: Implement queries**
+
+Create `libs/db/src/lib/queries/processed-events.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { eq } from 'drizzle-orm';
+import type { Db } from '../client.js';
+import { processedEvents } from '../schema/processed-events.js';
+
+/**
+ * Insert an event id. Returns `true` if this was the first time we saw it,
+ * `false` if it was already recorded (Stripe retry).
+ */
+export async function markEventProcessed(
+  db: Db,
+  stripeEventId: string,
+  eventType: string,
+): Promise {
+  const rows = await db
+    .insert(processedEvents)
+    .values({ stripeEventId, eventType })
+    .onConflictDoNothing({ target: processedEvents.stripeEventId })
+    .returning({ id: processedEvents.stripeEventId });
+  return rows.length > 0;
+}
+
+/**
+ * Remove a processed-event marker. Used for compensating deletes when a
+ * handler fails after the marker was written.
+ */
+export async function deleteProcessedEvent(db: Db, stripeEventId: string): Promise {
+  await db.delete(processedEvents).where(eq(processedEvents.stripeEventId, stripeEventId));
+}
+```
+
+- [ ] **Step 4: Run tests — should pass**
+
+Run: `cd /tmp/aaf-licensing && npx nx test db --testPathPattern=processed-events`
+Expected: PASS (4 tests). First run may take ~20s for Docker image pull.
+
+- [ ] **Step 5: Export from barrel**
+
+Replace `libs/db/src/index.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+export { createDb } from './lib/client.js';
+export type { Db } from './lib/client.js';
+export * from './lib/schema/index.js';
+export { markEventProcessed, deleteProcessedEvent } from './lib/queries/processed-events.js';
+```
+
+- [ ] **Step 6: Verify build**
+
+Run: `cd /tmp/aaf-licensing && npx nx build db`
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add libs/db/src/lib/queries/processed-events.ts libs/db/src/lib/queries/processed-events.spec.ts libs/db/src/index.ts
+git commit -m "feat(db): add processed-events queries with idempotency"
+```
+
+---
+
+### Task 9: Implement `licenses` queries
+
+**Files:**
+- Create: `libs/db/src/lib/queries/licenses.ts`
+- Create: `libs/db/src/lib/queries/licenses.spec.ts`
+- Modify: `libs/db/src/index.ts` (export)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `libs/db/src/lib/queries/licenses.spec.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import {
+  upsertLicense,
+  getLicense,
+  getLicensesByCustomerEmail,
+  revokeLicense,
+  updateLicenseToken,
+} from './licenses.js';
+import { startTestDb, type TestDb } from './test-helpers.js';
+
+const base = {
+  stripeCustomerId: 'cus_1',
+  stripeSubscriptionId: 'sub_1',
+  customerEmail: 'a@example.com',
+  tier: 'developer-seat' as const,
+  seats: 3,
+  expiresAt: new Date('2027-01-01T00:00:00Z'),
+  lastToken: 'token-v1',
+};
+
+describe('licenses queries', () => {
+  let testDb: TestDb;
+
+  beforeAll(async () => {
+    testDb = await startTestDb();
+  });
+
+  afterAll(async () => {
+    await testDb.cleanup();
+  });
+
+  describe('upsertLicense', () => {
+    it('inserts a new row keyed on stripe_subscription_id', async () => {
+      const row = await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_insert' });
+      expect(row.stripeSubscriptionId).toBe('sub_insert');
+      expect(row.seats).toBe(3);
+      expect(row.id).toBeDefined();
+    });
+
+    it('updates an existing row on repeat sub id', async () => {
+      await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_update', seats: 2 });
+      const updated = await upsertLicense(testDb.db, {
+        ...base,
+        stripeSubscriptionId: 'sub_update',
+        seats: 7,
+        lastToken: 'token-v2',
+      });
+      expect(updated.seats).toBe(7);
+      expect(updated.lastToken).toBe('token-v2');
+    });
+  });
+
+  describe('getLicense', () => {
+    it('returns the row when present', async () => {
+      await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_get' });
+      const found = await getLicense(testDb.db, 'sub_get');
+      expect(found?.stripeSubscriptionId).toBe('sub_get');
+    });
+
+    it('returns null when not found', async () => {
+      const found = await getLicense(testDb.db, 'sub_missing');
+      expect(found).toBeNull();
+    });
+  });
+
+  describe('getLicensesByCustomerEmail', () => {
+    it('returns all rows matching the email', async () => {
+      await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_e1', customerEmail: 'multi@example.com' });
+      await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_e2', customerEmail: 'multi@example.com' });
+      const rows = await getLicensesByCustomerEmail(testDb.db, 'multi@example.com');
+      expect(rows.length).toBeGreaterThanOrEqual(2);
+    });
+  });
+
+  describe('revokeLicense', () => {
+    it('sets revoked_at to now', async () => {
+      await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_revoke' });
+      const revoked = await revokeLicense(testDb.db, 'sub_revoke');
+      expect(revoked?.revokedAt).toBeInstanceOf(Date);
+    });
+
+    it('returns null for unknown subscription', async () => {
+      const result = await revokeLicense(testDb.db, 'sub_missing_revoke');
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('updateLicenseToken', () => {
+    it('replaces last_token and bumps issued_at', async () => {
+      const inserted = await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_token' });
+      const before = inserted.issuedAt;
+      await new Promise((r) => setTimeout(r, 10));
+      const updated = await updateLicenseToken(testDb.db, inserted.id, 'token-v99');
+      expect(updated.lastToken).toBe('token-v99');
+      expect(updated.issuedAt.getTime()).toBeGreaterThan(before.getTime());
+    });
+  });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd /tmp/aaf-licensing && npx nx test db --testPathPattern=queries/licenses`
+Expected: FAIL with "Cannot find module './licenses.js'"
+
+- [ ] **Step 3: Implement queries**
+
+Create `libs/db/src/lib/queries/licenses.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { eq, sql } from 'drizzle-orm';
+import type { Db } from '../client.js';
+import { licenses, type License, type NewLicense } from '../schema/licenses.js';
+
+export type UpsertLicenseInput = Omit & {
+  id?: string;
+};
+
+/**
+ * Insert a license or update the existing row keyed on stripe_subscription_id.
+ * Bumps issued_at and updated_at on every call.
+ */
+export async function upsertLicense(db: Db, input: UpsertLicenseInput): Promise {
+  const now = new Date();
+  const rows = await db
+    .insert(licenses)
+    .values({ ...input, issuedAt: now, updatedAt: now })
+    .onConflictDoUpdate({
+      target: licenses.stripeSubscriptionId,
+      set: {
+        customerEmail: input.customerEmail,
+        tier: input.tier,
+        seats: input.seats,
+        expiresAt: input.expiresAt,
+        lastToken: input.lastToken,
+        issuedAt: now,
+        updatedAt: now,
+      },
+    })
+    .returning();
+  return rows[0];
+}
+
+export async function getLicense(db: Db, stripeSubscriptionId: string): Promise {
+  const rows = await db
+    .select()
+    .from(licenses)
+    .where(eq(licenses.stripeSubscriptionId, stripeSubscriptionId))
+    .limit(1);
+  return rows[0] ?? null;
+}
+
+export async function getLicensesByCustomerEmail(db: Db, email: string): Promise {
+  return db.select().from(licenses).where(eq(licenses.customerEmail, email));
+}
+
+export async function revokeLicense(db: Db, stripeSubscriptionId: string): Promise {
+  const rows = await db
+    .update(licenses)
+    .set({ revokedAt: sql`now()`, updatedAt: sql`now()` })
+    .where(eq(licenses.stripeSubscriptionId, stripeSubscriptionId))
+    .returning();
+  return rows[0] ?? null;
+}
+
+export async function updateLicenseToken(db: Db, id: string, token: string): Promise {
+  const rows = await db
+    .update(licenses)
+    .set({ lastToken: token, issuedAt: sql`now()`, updatedAt: sql`now()` })
+    .where(eq(licenses.id, id))
+    .returning();
+  if (!rows[0]) throw new Error(`updateLicenseToken: no license with id=${id}`);
+  return rows[0];
+}
+```
+
+- [ ] **Step 4: Run tests — should pass**
+
+Run: `cd /tmp/aaf-licensing && npx nx test db --testPathPattern=queries/licenses`
+Expected: PASS (8 tests).
+
+- [ ] **Step 5: Export from barrel**
+
+Replace `libs/db/src/index.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+export { createDb } from './lib/client.js';
+export type { Db } from './lib/client.js';
+export * from './lib/schema/index.js';
+export { markEventProcessed, deleteProcessedEvent } from './lib/queries/processed-events.js';
+export {
+  upsertLicense,
+  getLicense,
+  getLicensesByCustomerEmail,
+  revokeLicense,
+  updateLicenseToken,
+} from './lib/queries/licenses.js';
+export type { UpsertLicenseInput } from './lib/queries/licenses.js';
+```
+
+- [ ] **Step 6: Verify build**
+
+Run: `cd /tmp/aaf-licensing && npx nx build db`
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add libs/db/src/lib/queries/licenses.ts libs/db/src/lib/queries/licenses.spec.ts libs/db/src/index.ts
+git commit -m "feat(db): add license queries (upsert, get, revoke, updateToken, byEmail)"
+```
+
+---
+
+## Phase C: Scaffold `apps/minting-service/`
+
+### Task 10: Scaffold Nx Node app
+
+**Files:**
+- Create: `apps/minting-service/project.json`
+- Create: `apps/minting-service/package.json`
+- Create: `apps/minting-service/tsconfig.json`
+- Create: `apps/minting-service/tsconfig.app.json`
+- Create: `apps/minting-service/tsconfig.spec.json`
+- Create: `apps/minting-service/src/` (scaffolded)
+- Create: `apps/minting-service/jest.config.ts`
+- Create: `apps/minting-service/eslint.config.mjs`
+- Modify: `tsconfig.base.json` (path alias if generator didn't add one)
+
+- [ ] **Step 1: Scaffold via Nx generator**
+
+Run:
+```bash
+cd /tmp/aaf-licensing
+npx nx g @nx/node:app apps/minting-service --name=minting-service --framework=none --bundler=none --unitTestRunner=jest --e2eTestRunner=none --linter=eslint --no-interactive
+```
+
+Expected: creates `apps/minting-service/` scaffolded as a Node app.
+
+- [ ] **Step 2: Remove generator's default `main.ts`**
+
+Delete `apps/minting-service/src/main.ts` and any default `app/` directory the generator created — we don't need them; this app is driven by Vercel functions in `api/`.
+
+Run:
+```bash
+cd /tmp/aaf-licensing
+rm -rf apps/minting-service/src/app apps/minting-service/src/main.ts
+```
+
+- [ ] **Step 3: Align tsconfig to NodeNext ESM**
+
+Open `apps/minting-service/tsconfig.app.json`. Ensure `compilerOptions` contains:
+
+```json
+{
+  "module": "NodeNext",
+  "moduleResolution": "NodeNext",
+  "target": "ES2022",
+  "outDir": "../../dist/apps/minting-service",
+  "emitDeclarationOnly": false,
+  "declaration": false
+}
+```
+
+Keep `include`, `exclude`, and `extends` as generated; add `"api/**/*.ts"` to `include` if absent.
+
+- [ ] **Step 4: Set package.json as ESM**
+
+Open `apps/minting-service/package.json`. Ensure it contains:
+```json
+{
+  "name": "@cacheplane/minting-service",
+  "type": "module",
+  "private": true,
+  "version": "0.0.1"
+}
+```
+
+- [ ] **Step 5: Verify lint and test targets work on an empty app**
+
+Run:
+```bash
+cd /tmp/aaf-licensing
+npx nx lint minting-service && npx nx test minting-service --passWithNoTests
+```
+Expected: both PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add apps/minting-service tsconfig.base.json
+git commit -m "feat(minting-service): scaffold Nx Node app"
+```
+
+---
+
+### Task 11: Add runtime dependencies and `.env.example`
+
+**Files:**
+- Modify: `apps/minting-service/package.json` (add deps)
+- Modify: `package.json` (add root deps if needed)
+- Create: `apps/minting-service/.env.example`
+
+- [ ] **Step 1: Install dependencies at the workspace root**
+
+Run:
+```bash
+cd /tmp/aaf-licensing
+pnpm add stripe resend
+pnpm add -D tsx @types/node
+```
+
+- [ ] **Step 2: Declare workspace deps in app package.json**
+
+Edit `apps/minting-service/package.json`. Add a `dependencies` block:
+
+```json
+"dependencies": {
+  "@cacheplane/db": "workspace:*",
+  "@cacheplane/licensing": "workspace:*",
+  "stripe": "*",
+  "resend": "*",
+  "drizzle-orm": "*",
+  "postgres": "*"
+}
+```
+
+(The `"*"` versions defer to the root lockfile via pnpm workspace resolution.)
+
+- [ ] **Step 3: Create `.env.example`**
+
+Create `apps/minting-service/.env.example`:
+
+```
+# Stripe
+STRIPE_SECRET_KEY=sk_test_replace_me
+STRIPE_WEBHOOK_SECRET=whsec_replace_me
+
+# Postgres (Vercel Postgres connection string)
+DATABASE_URL=postgres://user:pass@host:5432/db?sslmode=require
+
+# Resend
+RESEND_API_KEY=re_replace_me
+EMAIL_FROM=licenses@example.com
+
+# License signing (64 hex chars, 32 bytes Ed25519 private key)
+LICENSE_SIGNING_PRIVATE_KEY_HEX=0000000000000000000000000000000000000000000000000000000000000000
+
+# Optional: fallback TTL when a subscription has no current_period_end
+LICENSE_DEFAULT_TTL_DAYS=365
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add apps/minting-service/package.json apps/minting-service/.env.example package.json pnpm-lock.yaml
+git commit -m "feat(minting-service): add runtime deps and .env.example"
+```
+
+---
+
+## Phase D: Pure modules (`src/lib/`)
+
+### Task 12: Implement `env.ts` with validation
+
+**Files:**
+- Create: `apps/minting-service/src/lib/env.ts`
+- Create: `apps/minting-service/src/lib/env.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `apps/minting-service/src/lib/env.spec.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+const REQUIRED = {
+  STRIPE_SECRET_KEY: 'sk_test_xxx',
+  STRIPE_WEBHOOK_SECRET: 'whsec_xxx',
+  DATABASE_URL: 'postgres://u:p@h:5432/d',
+  RESEND_API_KEY: 're_xxx',
+  EMAIL_FROM: 'a@b.c',
+  LICENSE_SIGNING_PRIVATE_KEY_HEX: 'a'.repeat(64),
+};
+
+function setEnv(vars: Record) {
+  for (const [k, v] of Object.entries(vars)) {
+    if (v === undefined) delete process.env[k];
+    else process.env[k] = v;
+  }
+}
+
+describe('loadEnv', () => {
+  const originalEnv = { ...process.env };
+
+  afterEach(() => {
+    process.env = { ...originalEnv };
+  });
+
+  it('loads all required vars successfully', async () => {
+    setEnv(REQUIRED);
+    const { loadEnv } = await import('./env.js');
+    const env = loadEnv();
+    expect(env.STRIPE_SECRET_KEY).toBe('sk_test_xxx');
+    expect(env.LICENSE_DEFAULT_TTL_DAYS).toBe(365);
+  });
+
+  it('throws with a list of all missing vars', async () => {
+    setEnv({ ...REQUIRED, STRIPE_SECRET_KEY: undefined, DATABASE_URL: undefined });
+    const { loadEnv } = await import('./env.js');
+    expect(() => loadEnv()).toThrow(/STRIPE_SECRET_KEY.*DATABASE_URL|DATABASE_URL.*STRIPE_SECRET_KEY/);
+  });
+
+  it('throws when private key hex is the wrong length', async () => {
+    setEnv({ ...REQUIRED, LICENSE_SIGNING_PRIVATE_KEY_HEX: 'abc' });
+    const { loadEnv } = await import('./env.js');
+    expect(() => loadEnv()).toThrow(/64 hex chars/);
+  });
+
+  it('throws when private key hex has non-hex characters', async () => {
+    setEnv({ ...REQUIRED, LICENSE_SIGNING_PRIVATE_KEY_HEX: 'z'.repeat(64) });
+    const { loadEnv } = await import('./env.js');
+    expect(() => loadEnv()).toThrow(/64 hex chars/);
+  });
+
+  it('accepts a custom LICENSE_DEFAULT_TTL_DAYS', async () => {
+    setEnv({ ...REQUIRED, LICENSE_DEFAULT_TTL_DAYS: '30' });
+    const { loadEnv } = await import('./env.js');
+    const env = loadEnv();
+    expect(env.LICENSE_DEFAULT_TTL_DAYS).toBe(30);
+  });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=env`
+Expected: FAIL with "Cannot find module './env.js'"
+
+- [ ] **Step 3: Implement**
+
+Create `apps/minting-service/src/lib/env.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+const REQUIRED_VARS = [
+  'STRIPE_SECRET_KEY',
+  'STRIPE_WEBHOOK_SECRET',
+  'DATABASE_URL',
+  'RESEND_API_KEY',
+  'EMAIL_FROM',
+  'LICENSE_SIGNING_PRIVATE_KEY_HEX',
+] as const;
+
+export interface Env {
+  STRIPE_SECRET_KEY: string;
+  STRIPE_WEBHOOK_SECRET: string;
+  DATABASE_URL: string;
+  RESEND_API_KEY: string;
+  EMAIL_FROM: string;
+  LICENSE_SIGNING_PRIVATE_KEY_HEX: string;
+  LICENSE_DEFAULT_TTL_DAYS: number;
+}
+
+export function loadEnv(): Env {
+  const missing = REQUIRED_VARS.filter((k) => !process.env[k]);
+  if (missing.length > 0) {
+    throw new Error(`Missing required env vars: ${missing.join(', ')}`);
+  }
+
+  const keyHex = process.env['LICENSE_SIGNING_PRIVATE_KEY_HEX']!;
+  if (!/^[0-9a-f]{64}$/i.test(keyHex)) {
+    throw new Error('LICENSE_SIGNING_PRIVATE_KEY_HEX must be 64 hex chars (32 bytes)');
+  }
+
+  return {
+    STRIPE_SECRET_KEY: process.env['STRIPE_SECRET_KEY']!,
+    STRIPE_WEBHOOK_SECRET: process.env['STRIPE_WEBHOOK_SECRET']!,
+    DATABASE_URL: process.env['DATABASE_URL']!,
+    RESEND_API_KEY: process.env['RESEND_API_KEY']!,
+    EMAIL_FROM: process.env['EMAIL_FROM']!,
+    LICENSE_SIGNING_PRIVATE_KEY_HEX: keyHex,
+    LICENSE_DEFAULT_TTL_DAYS: Number(process.env['LICENSE_DEFAULT_TTL_DAYS'] ?? 365),
+  };
+}
+```
+
+- [ ] **Step 4: Run tests — should pass**
+
+Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=env`
+Expected: PASS (5 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add apps/minting-service/src/lib/env.ts apps/minting-service/src/lib/env.spec.ts
+git commit -m "feat(minting-service): add env var validation"
+```
+
+---
+
+### Task 13: Implement `tier.ts`
+
+**Files:**
+- Create: `apps/minting-service/src/lib/tier.ts`
+- Create: `apps/minting-service/src/lib/tier.spec.ts`
+
+**Context:** Stripe prices carry `metadata.cacheplane_tier` — one of `'developer-seat'` or `'app-deployment'`. `extractTier` pulls it and validates; `computeSeats` enforces per-tier seat rules (developer-seat scales with quantity; app-deployment is always 1).
+
+- [ ] **Step 1: Write the failing test**
+
+Create `apps/minting-service/src/lib/tier.spec.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { extractTier, computeSeats } from './tier.js';
+
+describe('extractTier', () => {
+  it('returns developer-seat from price metadata', () => {
+    expect(extractTier({ cacheplane_tier: 'developer-seat' })).toBe('developer-seat');
+  });
+
+  it('returns app-deployment from price metadata', () => {
+    expect(extractTier({ cacheplane_tier: 'app-deployment' })).toBe('app-deployment');
+  });
+
+  it('throws when cacheplane_tier is missing', () => {
+    expect(() => extractTier({})).toThrow(/cacheplane_tier/);
+  });
+
+  it('throws when cacheplane_tier is an unknown value', () => {
+    expect(() => extractTier({ cacheplane_tier: 'bogus' })).toThrow(/bogus/);
+  });
+
+  it('throws when metadata is null', () => {
+    expect(() => extractTier(null)).toThrow(/metadata/);
+  });
+});
+
+describe('computeSeats', () => {
+  it('returns the Stripe quantity for developer-seat', () => {
+    expect(computeSeats('developer-seat', 5)).toBe(5);
+  });
+
+  it('returns 1 for app-deployment regardless of quantity', () => {
+    expect(computeSeats('app-deployment', 10)).toBe(1);
+  });
+
+  it('defaults developer-seat to 1 when quantity is null', () => {
+    expect(computeSeats('developer-seat', null)).toBe(1);
+  });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=tier`
+Expected: FAIL with "Cannot find module './tier.js'"
+
+- [ ] **Step 3: Implement**
+
+Create `apps/minting-service/src/lib/tier.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import type { LicenseTier } from '@cacheplane/licensing';
+
+export type MintableTier = Extract;
+
+const VALID_TIERS: readonly MintableTier[] = ['developer-seat', 'app-deployment'] as const;
+
+/**
+ * Extract the Cacheplane tier from a Stripe price metadata bag.
+ * Throws if the field is missing or holds an unknown value.
+ */
+export function extractTier(metadata: Record | null | undefined): MintableTier {
+  if (!metadata) {
+    throw new Error('extractTier: price metadata is missing');
+  }
+  const raw = metadata['cacheplane_tier'];
+  if (!raw) {
+    throw new Error('extractTier: metadata.cacheplane_tier is missing');
+  }
+  if (!VALID_TIERS.includes(raw as MintableTier)) {
+    throw new Error(`extractTier: unknown cacheplane_tier value: ${raw}`);
+  }
+  return raw as MintableTier;
+}
+
+/**
+ * Compute the `seats` claim from the Stripe line-item quantity.
+ * - developer-seat: tracks Stripe quantity (minimum 1).
+ * - app-deployment: always 1.
+ */
+export function computeSeats(tier: MintableTier, quantity: number | null | undefined): number {
+  if (tier === 'app-deployment') return 1;
+  return quantity && quantity > 0 ? quantity : 1;
+}
+```
+
+- [ ] **Step 4: Run tests — should pass**
+
+Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=tier`
+Expected: PASS (8 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add apps/minting-service/src/lib/tier.ts apps/minting-service/src/lib/tier.spec.ts
+git commit -m "feat(minting-service): add tier extraction and seat computation"
+```
+
+---
+
+### Task 14: Implement `sign.ts` — `mintToken` wrapping `@cacheplane/licensing`
+
+**Files:**
+- Create: `apps/minting-service/src/lib/sign.ts`
+- Create: `apps/minting-service/src/lib/sign.spec.ts`
+
+**Context:** `mintToken` takes domain-friendly inputs (customer id, tier, seats, expiry as a Date) and a hex-encoded private key, and returns a signed token. It converts the Date to epoch seconds and fills in `iat`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `apps/minting-service/src/lib/sign.spec.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import * as ed from '@noble/ed25519';
+import { verifyLicense } from '@cacheplane/licensing';
+import { mintToken } from './sign.js';
+
+async function makeKeypair() {
+  const sk = ed.utils.randomPrivateKey();
+  const pk = await ed.getPublicKeyAsync(sk);
+  return {
+    skHex: Buffer.from(sk).toString('hex'),
+    pk,
+  };
+}
+
+describe('mintToken', () => {
+  it('returns a token verifiable with the matching public key', async () => {
+    const { skHex, pk } = await makeKeypair();
+
+    const token = await mintToken(
+      {
+        stripeCustomerId: 'cus_abc',
+        tier: 'developer-seat',
+        seats: 3,
+        expiresAt: new Date('2027-01-01T00:00:00Z'),
+      },
+      skHex,
+    );
+
+    const result = await verifyLicense(token, pk);
+    expect(result.ok).toBe(true);
+    if (result.ok) {
+      expect(result.claims.sub).toBe('cus_abc');
+      expect(result.claims.tier).toBe('developer-seat');
+      expect(result.claims.seats).toBe(3);
+      expect(result.claims.exp).toBe(Math.floor(new Date('2027-01-01T00:00:00Z').getTime() / 1000));
+      expect(result.claims.iat).toBeGreaterThan(0);
+    }
+  });
+
+  it('throws if the private key hex is malformed', async () => {
+    await expect(
+      mintToken(
+        {
+          stripeCustomerId: 'cus_x',
+          tier: 'app-deployment',
+          seats: 1,
+          expiresAt: new Date('2027-01-01T00:00:00Z'),
+        },
+        'not-hex',
+      ),
+    ).rejects.toThrow();
+  });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=sign`
+Expected: FAIL with "Cannot find module './sign.js'"
+
+- [ ] **Step 3: Implement**
+
+Create `apps/minting-service/src/lib/sign.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { signLicense, type LicenseClaims } from '@cacheplane/licensing';
+import type { MintableTier } from './tier.js';
+
+export interface MintInput {
+  stripeCustomerId: string;
+  tier: MintableTier;
+  seats: number;
+  expiresAt: Date;
+}
+
+/**
+ * Mint a signed license token. `privateKeyHex` is a 64-char hex string
+ * encoding a 32-byte Ed25519 private key.
+ */
+export async function mintToken(input: MintInput, privateKeyHex: string): Promise {
+  const privateKey = hexToBytes(privateKeyHex);
+  const now = Math.floor(Date.now() / 1000);
+  const claims: LicenseClaims = {
+    sub: input.stripeCustomerId,
+    tier: input.tier,
+    iat: now,
+    exp: Math.floor(input.expiresAt.getTime() / 1000),
+    seats: input.seats,
+  };
+  return signLicense(claims, privateKey);
+}
+
+function hexToBytes(hex: string): Uint8Array {
+  if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) {
+    throw new Error('mintToken: privateKeyHex must be an even-length hex string');
+  }
+  const bytes = new Uint8Array(hex.length / 2);
+  for (let i = 0; i < bytes.length; i++) {
+    bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+  }
+  return bytes;
+}
+```
+
+- [ ] **Step 4: Run tests — should pass**
+
+Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=sign`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /tmp/aaf-licensing
+git add apps/minting-service/src/lib/sign.ts apps/minting-service/src/lib/sign.spec.ts
+git commit -m "feat(minting-service): add mintToken wrapper over @cacheplane/licensing"
+```
+
+---
+
+### Task 15: Implement `email.ts` rendering (pure)
+
+**Files:**
+- Create: `apps/minting-service/src/lib/email.ts`
+- Create: `apps/minting-service/src/lib/email.spec.ts`
+
+**Context:** `renderLicenseEmail` is pure (no Resend, no network) so we can snapshot-test the body. `sendLicenseEmail` is the Resend wrapper and is covered by handler-level mocks.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `apps/minting-service/src/lib/email.spec.ts`:
+
+```ts
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { renderLicenseEmail } from './email.js';
+
+describe('renderLicenseEmail', () => {
+  it('includes the token wrapped in BEGIN/END delimiters in the text body', () => {
+    const out = renderLicenseEmail({
+      tier: 'developer-seat',
+      seats: 3,
+      token: 'PAYLOAD.SIG',
+      expiresAt: new Date('2027-04-20T00:00:00Z'),
+    });
+
+    expect(out.text).toContain('-----BEGIN CACHEPLANE LICENSE-----');
+    expect(out.text).toContain('PAYLOAD.SIG');
+    expect(out.text).toContain('-----END CACHEPLANE LICENSE-----');
+  });
+
+  it('subject includes tier and seat count with plural s for seats > 1', () => {
+    const out = renderLicenseEmail({
+      tier: 'developer-seat',
+      seats: 3,
+      token: 't.s',
+      expiresAt: new Date('2027-04-20T00:00:00Z'),
+    });
+    expect(out.subject).toBe('Your Cacheplane license — developer-seat (3 seats)');
+  });
+
+  it('subject uses singular seat for seats === 1', () => {
+    const out = renderLicenseEmail({
+      tier: 'app-deployment',
+      seats: 1,
+      token: 't.s',
+      expiresAt: new Date('2027-04-20T00:00:00Z'),
+    });
+    expect(out.subject).toBe('Your Cacheplane license — app-deployment (1 seat)');
+  });
+
+  it('includes ISO 8601 UTC expiry in text body', () => {
+    const out = renderLicenseEmail({
+      tier: 'developer-seat',
+      seats: 1,
+      token: 't.s',
+      expiresAt: new Date('2027-04-20T00:00:00Z'),
+    });
+    expect(out.text).toContain('Expires: 2027-04-20T00:00:00.000Z');
+  });
+
+  it('html body wraps the token in a monospace pre block', () => {
+    const out = renderLicenseEmail({
+      tier: 'developer-seat',
+      seats: 1,
+      token: 'PAYLOAD.SIG',
+      expiresAt: new Date('2027-04-20T00:00:00Z'),
+    });
+    expect(out.html).toContain('
+
+Docs: https://cacheplane.dev/docs/licensing
+Questions: reply to this email.
+
+-- The Cacheplane team
+`;
+
+  const html = `

Thanks for subscribing to Cacheplane.

+

Your license token is below. Set it as the CACHEPLANE_LICENSE environment variable in your application:

+
-----BEGIN CACHEPLANE LICENSE-----
+${escapeHtml(vars.token)}
+-----END CACHEPLANE LICENSE-----
+

Tier: ${escapeHtml(vars.tier)}
+Seats: ${vars.seats}
+Expires: ${escapeHtml(expiresIso)}

+

Installation:

+
export CACHEPLANE_LICENSE="<paste token above>"
+

Docs: cacheplane.dev/docs/licensing
+Questions: reply to this email.

+

-- The Cacheplane team

+`; + + return { subject, text, html }; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Send a license email via Resend. Throws on Resend errors so the caller + * (webhook handler) can fail the request and trigger Stripe retry. + */ +export async function sendLicenseEmail(args: { + resendApiKey: string; + from: string; + to: string; + vars: LicenseEmailVars; +}): Promise<{ resendId: string }> { + const resend = new Resend(args.resendApiKey); + const rendered = renderLicenseEmail(args.vars); + const result = await resend.emails.send({ + from: args.from, + to: args.to, + subject: rendered.subject, + text: rendered.text, + html: rendered.html, + }); + if (result.error) { + throw new Error(`Resend send failed: ${result.error.message}`); + } + if (!result.data?.id) { + throw new Error('Resend send returned no id'); + } + return { resendId: result.data.id }; +} +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=email` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/src/lib/email.ts apps/minting-service/src/lib/email.spec.ts +git commit -m "feat(minting-service): add license email renderer and Resend wrapper" +``` + +--- + +## Phase E: Handlers + +### Task 16: Add `stripe.ts` — shared Stripe SDK singleton + +**Files:** +- Create: `apps/minting-service/src/lib/stripe.ts` + +- [ ] **Step 1: Implement** + +Create `apps/minting-service/src/lib/stripe.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import Stripe from 'stripe'; + +let client: Stripe | null = null; + +/** + * Lazy-init a Stripe SDK client. Lives in its own module so tests can + * replace it via jest.mock without the full env being loaded. + */ +export function getStripe(apiKey: string): Stripe { + if (!client) { + client = new Stripe(apiKey, { apiVersion: '2024-06-20' }); + } + return client; +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd /tmp/aaf-licensing && npx nx build minting-service` +Expected: PASS (or if no build target exists, run `npx tsc --noEmit -p apps/minting-service/tsconfig.app.json`). + +- [ ] **Step 3: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/src/lib/stripe.ts +git commit -m "feat(minting-service): add Stripe SDK singleton" +``` + +--- + +### Task 17: Implement `handlers.ts` — idempotency + dispatch skeleton + +**Files:** +- Create: `apps/minting-service/src/lib/handlers.ts` +- Create: `apps/minting-service/src/lib/handlers.spec.ts` + +**Context:** We build handlers in layers. This task covers the `handleEvent` orchestrator (idempotency check, dispatch, compensating delete on error) plus the three handler stubs. Subsequent tasks (18, 19, 20) fill in each handler with tests. + +- [ ] **Step 1: Write the failing test for idempotency + dispatch** + +Create `apps/minting-service/src/lib/handlers.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type Stripe from 'stripe'; +import { handleEvent, type HandlerDeps } from './handlers.js'; + +function makeDeps(overrides: Partial = {}): HandlerDeps { + return { + db: {} as any, + stripe: {} as any, + markEventProcessed: jest.fn().mockResolvedValue(true), + deleteProcessedEvent: jest.fn().mockResolvedValue(undefined), + upsertLicense: jest.fn(), + getLicense: jest.fn(), + revokeLicense: jest.fn(), + mintToken: jest.fn(), + sendLicenseEmail: jest.fn(), + privateKeyHex: 'a'.repeat(64), + resendApiKey: 're_test', + emailFrom: 'a@b.c', + defaultTtlDays: 365, + ...overrides, + }; +} + +function evt(type: string, obj: unknown = {}): Stripe.Event { + return { id: `evt_${type}`, type, data: { object: obj } } as Stripe.Event; +} + +describe('handleEvent', () => { + it('returns early if markEventProcessed returns false (duplicate)', async () => { + const deps = makeDeps({ + markEventProcessed: jest.fn().mockResolvedValue(false), + }); + await handleEvent(evt('customer.subscription.deleted', { id: 'sub_x' }), deps); + expect(deps.revokeLicense).not.toHaveBeenCalled(); + }); + + it('no-ops on unknown event types', async () => { + const deps = makeDeps(); + await handleEvent(evt('invoice.payment_succeeded'), deps); + expect(deps.revokeLicense).not.toHaveBeenCalled(); + expect(deps.upsertLicense).not.toHaveBeenCalled(); + }); + + it('compensating-deletes the processed-event marker when handler throws', async () => { + const boom = new Error('boom'); + const deps = makeDeps({ + revokeLicense: jest.fn().mockRejectedValue(boom), + }); + await expect( + handleEvent(evt('customer.subscription.deleted', { id: 'sub_boom' }), deps), + ).rejects.toBe(boom); + expect(deps.deleteProcessedEvent).toHaveBeenCalledWith(deps.db, 'evt_customer.subscription.deleted'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=handlers` +Expected: FAIL with "Cannot find module './handlers.js'" + +- [ ] **Step 3: Implement skeleton** + +Create `apps/minting-service/src/lib/handlers.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type Stripe from 'stripe'; +import type { + Db, + License, + UpsertLicenseInput, +} from '@cacheplane/db'; +import type { MintInput } from './sign.js'; +import type { LicenseEmailVars } from './email.js'; + +/** + * All external collaborators are injected so handlers are unit-testable. + */ +export interface HandlerDeps { + db: Db; + stripe: Stripe; + markEventProcessed: (db: Db, id: string, type: string) => Promise; + deleteProcessedEvent: (db: Db, id: string) => Promise; + upsertLicense: (db: Db, input: UpsertLicenseInput) => Promise; + getLicense: (db: Db, subId: string) => Promise; + revokeLicense: (db: Db, subId: string) => Promise; + mintToken: (input: MintInput, privateKeyHex: string) => Promise; + sendLicenseEmail: (args: { + resendApiKey: string; + from: string; + to: string; + vars: LicenseEmailVars; + }) => Promise<{ resendId: string }>; + privateKeyHex: string; + resendApiKey: string; + emailFrom: string; + defaultTtlDays: number; +} + +export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promise { + const firstTime = await deps.markEventProcessed(deps.db, event.id, event.type); + if (!firstTime) return; + + try { + switch (event.type) { + case 'checkout.session.completed': + await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, deps); + break; + case 'customer.subscription.updated': + await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, deps); + break; + case 'customer.subscription.deleted': + await handleSubscriptionDeleted(event.data.object as Stripe.Subscription, deps); + break; + default: + return; + } + } catch (err) { + await deps.deleteProcessedEvent(deps.db, event.id); + throw err; + } +} + +export async function handleCheckoutCompleted( + _session: Stripe.Checkout.Session, + _deps: HandlerDeps, +): Promise { + throw new Error('handleCheckoutCompleted: not yet implemented'); +} + +export async function handleSubscriptionUpdated( + _sub: Stripe.Subscription, + _deps: HandlerDeps, +): Promise { + throw new Error('handleSubscriptionUpdated: not yet implemented'); +} + +export async function handleSubscriptionDeleted( + sub: Stripe.Subscription, + deps: HandlerDeps, +): Promise { + await deps.revokeLicense(deps.db, sub.id); +} +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=handlers` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/src/lib/handlers.ts apps/minting-service/src/lib/handlers.spec.ts +git commit -m "feat(minting-service): add handleEvent dispatcher with idempotency + compensating delete" +``` + +--- + +### Task 18: Implement `handleCheckoutCompleted` + +**Files:** +- Modify: `apps/minting-service/src/lib/handlers.ts` +- Modify: `apps/minting-service/src/lib/handlers.spec.ts` + +**Context:** The session object in `checkout.session.completed` doesn't include line items by default. We call `stripe.checkout.sessions.retrieve(id, { expand: ['line_items.data.price'] })` to get them, then extract tier, seats, and expiry from the subscription's `current_period_end`. + +- [ ] **Step 1: Add failing tests** + +Append to `apps/minting-service/src/lib/handlers.spec.ts` (inside the existing file, after the `describe('handleEvent', ...)` block): + +```ts +describe('handleCheckoutCompleted', () => { + function baseSession(overrides: any = {}): Stripe.Checkout.Session { + return { + id: 'cs_test', + customer: 'cus_x', + subscription: 'sub_x', + customer_details: { email: 'a@b.c' }, + ...overrides, + } as Stripe.Checkout.Session; + } + + function baseDeps(): HandlerDeps { + const lineItem = { + data: [ + { + quantity: 2, + price: { metadata: { cacheplane_tier: 'developer-seat' } }, + }, + ], + }; + const sub = { current_period_end: 1_800_000_000, id: 'sub_x' }; + const expandedSession = baseSession({ line_items: lineItem }); + + return makeDeps({ + stripe: { + checkout: { + sessions: { + retrieve: jest.fn().mockResolvedValue(expandedSession), + }, + }, + subscriptions: { + retrieve: jest.fn().mockResolvedValue(sub), + }, + } as any, + mintToken: jest.fn().mockResolvedValue('TOKEN.SIG'), + upsertLicense: jest.fn().mockImplementation((_db, input) => + Promise.resolve({ ...input, id: 'lic_1', createdAt: new Date(), updatedAt: new Date(), issuedAt: new Date(), revokedAt: null }), + ), + sendLicenseEmail: jest.fn().mockResolvedValue({ resendId: 're_1' }), + }); + } + + it('upserts a license row and sends an email', async () => { + const deps = baseDeps(); + await handleEvent( + { id: 'evt_co', type: 'checkout.session.completed', data: { object: baseSession() } } as Stripe.Event, + deps, + ); + expect(deps.upsertLicense).toHaveBeenCalledTimes(1); + const upsertArg = (deps.upsertLicense as jest.Mock).mock.calls[0][1]; + expect(upsertArg.stripeSubscriptionId).toBe('sub_x'); + expect(upsertArg.tier).toBe('developer-seat'); + expect(upsertArg.seats).toBe(2); + expect(upsertArg.customerEmail).toBe('a@b.c'); + expect(upsertArg.lastToken).toBe('TOKEN.SIG'); + expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); + + it('throws when cacheplane_tier is missing from price metadata', async () => { + const deps = baseDeps(); + (deps.stripe.checkout.sessions.retrieve as jest.Mock).mockResolvedValueOnce( + baseSession({ line_items: { data: [{ quantity: 1, price: { metadata: {} } }] } }), + ); + await expect( + handleEvent( + { id: 'evt_co2', type: 'checkout.session.completed', data: { object: baseSession() } } as Stripe.Event, + deps, + ), + ).rejects.toThrow(/cacheplane_tier/); + expect(deps.deleteProcessedEvent).toHaveBeenCalledWith(deps.db, 'evt_co2'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=handlers` +Expected: 2 new tests FAIL with "handleCheckoutCompleted: not yet implemented" + +- [ ] **Step 3: Implement `handleCheckoutCompleted`** + +In `apps/minting-service/src/lib/handlers.ts`, replace the stub: + +```ts +import { extractTier, computeSeats } from './tier.js'; + +export async function handleCheckoutCompleted( + session: Stripe.Checkout.Session, + deps: HandlerDeps, +): Promise { + const expanded = await deps.stripe.checkout.sessions.retrieve(session.id, { + expand: ['line_items.data.price'], + }); + const lineItem = expanded.line_items?.data?.[0]; + if (!lineItem) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no line items`); + } + const priceMetadata = (lineItem.price?.metadata ?? {}) as Record; + const tier = extractTier(priceMetadata); + const seats = computeSeats(tier, lineItem.quantity); + + const subId = typeof expanded.subscription === 'string' + ? expanded.subscription + : expanded.subscription?.id; + if (!subId) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no subscription`); + } + const sub = await deps.stripe.subscriptions.retrieve(subId); + const expiresAt = sub.current_period_end + ? new Date(sub.current_period_end * 1000) + : new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000); + + const customerId = typeof expanded.customer === 'string' ? expanded.customer : expanded.customer?.id; + if (!customerId) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer`); + } + const email = expanded.customer_details?.email; + if (!email) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer email`); + } + + const token = await deps.mintToken( + { stripeCustomerId: customerId, tier, seats, expiresAt }, + deps.privateKeyHex, + ); + + await deps.upsertLicense(deps.db, { + stripeCustomerId: customerId, + stripeSubscriptionId: subId, + customerEmail: email, + tier, + seats, + expiresAt, + lastToken: token, + }); + + await deps.sendLicenseEmail({ + resendApiKey: deps.resendApiKey, + from: deps.emailFrom, + to: email, + vars: { tier, seats, token, expiresAt }, + }); +} +``` + +- [ ] **Step 4: Run tests — all should pass** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=handlers` +Expected: PASS (5 tests now). + +- [ ] **Step 5: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/src/lib/handlers.ts apps/minting-service/src/lib/handlers.spec.ts +git commit -m "feat(minting-service): implement handleCheckoutCompleted" +``` + +--- + +### Task 19: Implement `handleSubscriptionUpdated` with material-change check + +**Files:** +- Modify: `apps/minting-service/src/lib/handlers.ts` +- Modify: `apps/minting-service/src/lib/handlers.spec.ts` + +**Context:** Stripe fires `customer.subscription.updated` for any mutation (card change, metadata edit, period renewal, plan change). We only want to mint a new token and email the customer when the claims shape changes — i.e. tier, seats, or expiry differs. + +- [ ] **Step 1: Add failing tests** + +Append to `apps/minting-service/src/lib/handlers.spec.ts`: + +```ts +describe('handleSubscriptionUpdated', () => { + function sub(overrides: any = {}): Stripe.Subscription { + return { + id: 'sub_u', + customer: 'cus_u', + current_period_end: 1_800_000_000, + items: { + data: [ + { + quantity: 3, + price: { metadata: { cacheplane_tier: 'developer-seat' } }, + }, + ], + }, + ...overrides, + } as Stripe.Subscription; + } + + function existingLicense(overrides: Partial = {}): License { + return { + id: 'lic_u', + stripeCustomerId: 'cus_u', + stripeSubscriptionId: 'sub_u', + customerEmail: 'u@example.com', + tier: 'developer-seat', + seats: 3, + expiresAt: new Date(1_800_000_000 * 1000), + revokedAt: null, + lastToken: 'OLD.TOKEN', + issuedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as License; + } + + function deps(license: License | null): HandlerDeps { + return makeDeps({ + getLicense: jest.fn().mockResolvedValue(license), + upsertLicense: jest.fn().mockImplementation((_db, input) => + Promise.resolve({ ...(license ?? {}), ...input, id: 'lic_u', createdAt: new Date(), updatedAt: new Date(), issuedAt: new Date(), revokedAt: null }), + ), + mintToken: jest.fn().mockResolvedValue('NEW.TOKEN'), + sendLicenseEmail: jest.fn().mockResolvedValue({ resendId: 're_u' }), + stripe: { + checkout: { sessions: { retrieve: jest.fn() } }, + subscriptions: { retrieve: jest.fn() }, + } as any, + }); + } + + it('upserts without minting or emailing when claims are unchanged', async () => { + const d = deps(existingLicense()); + // Customer email from existing license must match current Stripe data — + // use the license email for the existing row to make the claim comparison clean. + await handleEvent( + { id: 'evt_u_noop', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).not.toHaveBeenCalled(); + expect(d.sendLicenseEmail).not.toHaveBeenCalled(); + expect(d.upsertLicense).toHaveBeenCalledTimes(1); + const arg = (d.upsertLicense as jest.Mock).mock.calls[0][1]; + expect(arg.lastToken).toBe('OLD.TOKEN'); + }); + + it('mints and emails when seats change', async () => { + const d = deps(existingLicense({ seats: 2 })); + await handleEvent( + { id: 'evt_u_seats', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).toHaveBeenCalledTimes(1); + expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); + const arg = (d.upsertLicense as jest.Mock).mock.calls[0][1]; + expect(arg.lastToken).toBe('NEW.TOKEN'); + expect(arg.seats).toBe(3); + }); + + it('mints and emails when tier changes', async () => { + const d = deps(existingLicense({ tier: 'app-deployment', seats: 1 })); + await handleEvent( + { id: 'evt_u_tier', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).toHaveBeenCalledTimes(1); + expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); + + it('mints and emails when expires_at changes', async () => { + const d = deps(existingLicense({ expiresAt: new Date(1_700_000_000 * 1000) })); + await handleEvent( + { id: 'evt_u_exp', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).toHaveBeenCalledTimes(1); + expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); + + it('mints and emails when no existing license is found (first time)', async () => { + const d = deps(null); + // Need customer retrieval for email — emulate a Stripe customer lookup. + (d.stripe.subscriptions.retrieve as jest.Mock) = jest.fn().mockResolvedValue({ latest_invoice: null }); + // Test expects an email source. The implementation pulls it from the license if present, otherwise from subscription metadata or Stripe customer. + // Emulate customer retrieval for emails: + (d.stripe as any).customers = { + retrieve: jest.fn().mockResolvedValue({ email: 'new@example.com' }), + }; + await handleEvent( + { id: 'evt_u_new', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).toHaveBeenCalledTimes(1); + expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); + const sendArg = (d.sendLicenseEmail as jest.Mock).mock.calls[0][0]; + expect(sendArg.to).toBe('new@example.com'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=handlers` +Expected: 5 new tests FAIL with "handleSubscriptionUpdated: not yet implemented" + +- [ ] **Step 3: Implement `handleSubscriptionUpdated`** + +In `apps/minting-service/src/lib/handlers.ts`, replace the stub: + +```ts +export async function handleSubscriptionUpdated( + sub: Stripe.Subscription, + deps: HandlerDeps, +): Promise { + const lineItem = sub.items?.data?.[0]; + if (!lineItem) { + throw new Error(`handleSubscriptionUpdated: subscription ${sub.id} has no items`); + } + const priceMetadata = (lineItem.price?.metadata ?? {}) as Record; + const tier = extractTier(priceMetadata); + const seats = computeSeats(tier, lineItem.quantity); + const expiresAt = sub.current_period_end + ? new Date(sub.current_period_end * 1000) + : new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000); + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; + if (!customerId) { + throw new Error(`handleSubscriptionUpdated: subscription ${sub.id} has no customer`); + } + + const existing = await deps.getLicense(deps.db, sub.id); + + const claimsUnchanged = + existing !== null && + existing.tier === tier && + existing.seats === seats && + existing.expiresAt.getTime() === expiresAt.getTime(); + + // Email source: prefer existing license (captured at checkout), else pull + // from Stripe customer. + let email = existing?.customerEmail; + if (!email) { + const customer = await deps.stripe.customers.retrieve(customerId); + if ('deleted' in customer && customer.deleted) { + throw new Error(`handleSubscriptionUpdated: customer ${customerId} is deleted`); + } + email = (customer as Stripe.Customer).email ?? undefined; + if (!email) { + throw new Error(`handleSubscriptionUpdated: no email for customer ${customerId}`); + } + } + + if (claimsUnchanged && existing) { + await deps.upsertLicense(deps.db, { + stripeCustomerId: existing.stripeCustomerId, + stripeSubscriptionId: existing.stripeSubscriptionId, + customerEmail: existing.customerEmail, + tier: existing.tier, + seats: existing.seats, + expiresAt: existing.expiresAt, + lastToken: existing.lastToken, + }); + return; + } + + const token = await deps.mintToken( + { stripeCustomerId: customerId, tier, seats, expiresAt }, + deps.privateKeyHex, + ); + + await deps.upsertLicense(deps.db, { + stripeCustomerId: customerId, + stripeSubscriptionId: sub.id, + customerEmail: email, + tier, + seats, + expiresAt, + lastToken: token, + }); + + await deps.sendLicenseEmail({ + resendApiKey: deps.resendApiKey, + from: deps.emailFrom, + to: email, + vars: { tier, seats, token, expiresAt }, + }); +} +``` + +- [ ] **Step 4: Update `HandlerDeps` interface to include `customers.retrieve`** + +No change needed — `HandlerDeps.stripe` is typed as `Stripe`, which already includes `customers.retrieve`. + +- [ ] **Step 5: Run tests — all should pass** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=handlers` +Expected: PASS (10 tests now). + +- [ ] **Step 6: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/src/lib/handlers.ts apps/minting-service/src/lib/handlers.spec.ts +git commit -m "feat(minting-service): implement handleSubscriptionUpdated with material-change check" +``` + +--- + +### Task 20: Verify `handleSubscriptionDeleted` behavior + +**Files:** +- Modify: `apps/minting-service/src/lib/handlers.spec.ts` + +**Context:** The handler itself was implemented in Task 17; add explicit tests to lock in the contract (revoke only, no email, no token mint). + +- [ ] **Step 1: Add tests** + +Append to `apps/minting-service/src/lib/handlers.spec.ts`: + +```ts +describe('handleSubscriptionDeleted', () => { + it('calls revokeLicense and does not email or mint', async () => { + const d = makeDeps({ + revokeLicense: jest.fn().mockResolvedValue({ id: 'lic_d' }), + }); + await handleEvent( + { id: 'evt_del', type: 'customer.subscription.deleted', data: { object: { id: 'sub_d' } } } as Stripe.Event, + d, + ); + expect(d.revokeLicense).toHaveBeenCalledWith(d.db, 'sub_d'); + expect(d.mintToken).not.toHaveBeenCalled(); + expect(d.sendLicenseEmail).not.toHaveBeenCalled(); + }); + + it('is idempotent — no throw if license is already absent', async () => { + const d = makeDeps({ + revokeLicense: jest.fn().mockResolvedValue(null), + }); + await expect( + handleEvent( + { id: 'evt_del2', type: 'customer.subscription.deleted', data: { object: { id: 'sub_nope' } } } as Stripe.Event, + d, + ), + ).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=handlers` +Expected: PASS (12 tests total). + +- [ ] **Step 3: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/src/lib/handlers.spec.ts +git commit -m "test(minting-service): lock in handleSubscriptionDeleted contract" +``` + +--- + +## Phase F: API routes + +### Task 21: Implement `api/health.ts` + +**Files:** +- Create: `apps/minting-service/api/health.ts` + +- [ ] **Step 1: Implement** + +Create `apps/minting-service/api/health.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { VercelRequest, VercelResponse } from '@vercel/node'; + +export default function handler(_req: VercelRequest, res: VercelResponse): void { + res.status(200).json({ ok: true }); +} +``` + +- [ ] **Step 2: Install Vercel types** + +Run: +```bash +cd /tmp/aaf-licensing +pnpm add -D @vercel/node +``` + +- [ ] **Step 3: Verify typecheck** + +Run: `cd /tmp/aaf-licensing && npx tsc --noEmit -p apps/minting-service/tsconfig.app.json` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/api/health.ts package.json pnpm-lock.yaml +git commit -m "feat(minting-service): add /api/health probe" +``` + +--- + +### Task 22: Implement `api/stripe-webhook.ts` + +**Files:** +- Create: `apps/minting-service/api/stripe-webhook.ts` + +**Context:** This file is the composition root. It loads env, reads the raw request body (Stripe signature verification requires the unparsed bytes), verifies the signature, builds a `HandlerDeps` object binding all the pure functions + DB client + Stripe SDK, and calls `handleEvent`. No unit test — this is a thin adapter and is exercised by the manual smoke test in Task 26. + +- [ ] **Step 1: Implement** + +Create `apps/minting-service/api/stripe-webhook.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import type { IncomingMessage } from 'node:http'; +import { + createDb, + markEventProcessed, + deleteProcessedEvent, + upsertLicense, + getLicense, + revokeLicense, +} from '@cacheplane/db'; +import { loadEnv } from '../src/lib/env.js'; +import { getStripe } from '../src/lib/stripe.js'; +import { mintToken } from '../src/lib/sign.js'; +import { sendLicenseEmail } from '../src/lib/email.js'; +import { handleEvent, type HandlerDeps } from '../src/lib/handlers.js'; + +export const config = { api: { bodyParser: false } }; + +async function readRawBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +export default async function handler(req: VercelRequest, res: VercelResponse): Promise { + if (req.method !== 'POST') { + res.status(405).end(); + return; + } + + const env = loadEnv(); + const stripe = getStripe(env.STRIPE_SECRET_KEY); + + const rawBody = await readRawBody(req); + const sig = req.headers['stripe-signature']; + if (typeof sig !== 'string') { + res.status(400).send('missing signature'); + return; + } + + let event; + try { + event = stripe.webhooks.constructEvent(rawBody, sig, env.STRIPE_WEBHOOK_SECRET); + } catch (err) { + console.error('stripe signature verification failed', err); + res.status(400).send('invalid signature'); + return; + } + + const db = createDb(env.DATABASE_URL); + const deps: HandlerDeps = { + db, + stripe, + markEventProcessed, + deleteProcessedEvent, + upsertLicense, + getLicense, + revokeLicense, + mintToken, + sendLicenseEmail, + privateKeyHex: env.LICENSE_SIGNING_PRIVATE_KEY_HEX, + resendApiKey: env.RESEND_API_KEY, + emailFrom: env.EMAIL_FROM, + defaultTtlDays: env.LICENSE_DEFAULT_TTL_DAYS, + }; + + try { + await handleEvent(event, deps); + res.status(200).json({ received: true }); + } catch (err) { + console.error('webhook handler error', { eventId: event.id, type: event.type, err }); + res.status(500).send('internal error'); + } finally { + await db.close(); + } +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `cd /tmp/aaf-licensing && npx tsc --noEmit -p apps/minting-service/tsconfig.app.json` +Expected: PASS + +- [ ] **Step 3: Verify lint** + +Run: `cd /tmp/aaf-licensing && npx nx lint minting-service` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/api/stripe-webhook.ts +git commit -m "feat(minting-service): add /api/stripe-webhook endpoint" +``` + +--- + +## Phase G: Deployment configuration + +### Task 23: Create `vercel.json` + +**Files:** +- Create: `apps/minting-service/vercel.json` + +- [ ] **Step 1: Implement** + +Create `apps/minting-service/vercel.json`: + +```json +{ + "buildCommand": "cd ../.. && npx nx build minting-service", + "outputDirectory": "../../dist/apps/minting-service", + "installCommand": "cd ../.. && pnpm install --frozen-lockfile", + "framework": null, + "functions": { + "api/*.ts": { + "runtime": "nodejs20.x", + "maxDuration": 10 + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/vercel.json +git commit -m "feat(minting-service): add Vercel deployment config" +``` + +--- + +## Phase H: Manual re-mint CLI + +### Task 24: Implement `scripts/remint.ts` + +**Files:** +- Create: `apps/minting-service/scripts/remint.ts` +- Create: `apps/minting-service/scripts/remint.spec.ts` +- Modify: `apps/minting-service/project.json` (add `remint` target) + +**Context:** A standalone script the operator runs against prod DB to re-send a license email. Reuses `renderLicenseEmail`/`sendLicenseEmail` from Section D. The script's flag parser + core logic are unit-tested; the actual `main()` entry that reads `process.argv` is thin. + +- [ ] **Step 1: Write the failing test** + +Create `apps/minting-service/scripts/remint.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { parseArgs, runRemint, type RemintDeps } from './remint.js'; +import type { License } from '@cacheplane/db'; + +function makeLicense(overrides: Partial = {}): License { + return { + id: 'lic_1', + stripeCustomerId: 'cus_1', + stripeSubscriptionId: 'sub_1', + customerEmail: 'a@example.com', + tier: 'developer-seat', + seats: 3, + expiresAt: new Date('2027-01-01T00:00:00Z'), + revokedAt: null, + lastToken: 'TOKEN.SIG', + issuedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as License; +} + +function makeDeps(overrides: Partial = {}): RemintDeps { + return { + db: {} as any, + getLicense: jest.fn().mockResolvedValue(makeLicense()), + updateLicenseToken: jest.fn().mockImplementation(async (_db, _id, token) => + makeLicense({ lastToken: token, issuedAt: new Date() }), + ), + mintToken: jest.fn().mockResolvedValue('NEW.TOKEN'), + sendLicenseEmail: jest.fn().mockResolvedValue({ resendId: 're_1' }), + resendApiKey: 're_test', + emailFrom: 'from@example.com', + privateKeyHex: 'a'.repeat(64), + ...overrides, + }; +} + +describe('parseArgs', () => { + it('parses --sub', () => { + expect(parseArgs(['--sub=sub_abc']).sub).toBe('sub_abc'); + }); + + it('defaults dryRun and newToken to false, to to undefined', () => { + const a = parseArgs(['--sub=sub_x']); + expect(a.dryRun).toBe(false); + expect(a.newToken).toBe(false); + expect(a.to).toBeUndefined(); + }); + + it('recognises --dry-run, --new-token, --to', () => { + const a = parseArgs(['--sub=sub_x', '--dry-run', '--new-token', '--to=b@b.c']); + expect(a.dryRun).toBe(true); + expect(a.newToken).toBe(true); + expect(a.to).toBe('b@b.c'); + }); + + it('throws if --sub is missing', () => { + expect(() => parseArgs([])).toThrow(/--sub/); + }); +}); + +describe('runRemint', () => { + it('sends email with existing token by default', async () => { + const deps = makeDeps(); + const result = await runRemint({ sub: 'sub_1', dryRun: false, newToken: false }, deps); + expect(deps.mintToken).not.toHaveBeenCalled(); + expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); + const sendArg = (deps.sendLicenseEmail as jest.Mock).mock.calls[0][0]; + expect(sendArg.to).toBe('a@example.com'); + expect(sendArg.vars.token).toBe('TOKEN.SIG'); + expect(result.sent).toBe(true); + }); + + it('overrides destination with --to', async () => { + const deps = makeDeps(); + await runRemint({ sub: 'sub_1', dryRun: false, newToken: false, to: 'new@b.c' }, deps); + const sendArg = (deps.sendLicenseEmail as jest.Mock).mock.calls[0][0]; + expect(sendArg.to).toBe('new@b.c'); + }); + + it('mints and persists a new token with --new-token', async () => { + const deps = makeDeps(); + await runRemint({ sub: 'sub_1', dryRun: false, newToken: true }, deps); + expect(deps.mintToken).toHaveBeenCalledTimes(1); + expect(deps.updateLicenseToken).toHaveBeenCalledTimes(1); + const sendArg = (deps.sendLicenseEmail as jest.Mock).mock.calls[0][0]; + expect(sendArg.vars.token).toBe('NEW.TOKEN'); + }); + + it('does not send email with --dry-run', async () => { + const deps = makeDeps(); + const result = await runRemint({ sub: 'sub_1', dryRun: true, newToken: false }, deps); + expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); + expect(result.sent).toBe(false); + expect(result.preview).toBeDefined(); + }); + + it('refuses when license is revoked', async () => { + const deps = makeDeps({ + getLicense: jest.fn().mockResolvedValue(makeLicense({ revokedAt: new Date() })), + }); + await expect( + runRemint({ sub: 'sub_1', dryRun: false, newToken: false }, deps), + ).rejects.toThrow(/revoked/); + }); + + it('throws when license does not exist', async () => { + const deps = makeDeps({ getLicense: jest.fn().mockResolvedValue(null) }); + await expect( + runRemint({ sub: 'sub_nope', dryRun: false, newToken: false }, deps), + ).rejects.toThrow(/sub_nope/); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=remint` +Expected: FAIL with "Cannot find module './remint.js'" + +- [ ] **Step 3: Implement** + +Create `apps/minting-service/scripts/remint.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + createDb, + getLicense, + updateLicenseToken, + type Db, + type License, +} from '@cacheplane/db'; +import { loadEnv } from '../src/lib/env.js'; +import { mintToken } from '../src/lib/sign.js'; +import { sendLicenseEmail, renderLicenseEmail, type RenderedEmail } from '../src/lib/email.js'; + +export interface RemintArgs { + sub: string; + dryRun: boolean; + newToken: boolean; + to?: string; +} + +export interface RemintDeps { + db: Db; + getLicense: (db: Db, subId: string) => Promise; + updateLicenseToken: (db: Db, id: string, token: string) => Promise; + mintToken: typeof mintToken; + sendLicenseEmail: typeof sendLicenseEmail; + resendApiKey: string; + emailFrom: string; + privateKeyHex: string; +} + +export interface RemintResult { + sent: boolean; + preview?: RenderedEmail; +} + +export function parseArgs(argv: string[]): RemintArgs { + const out: Partial = { dryRun: false, newToken: false }; + for (const arg of argv) { + if (arg.startsWith('--sub=')) out.sub = arg.slice('--sub='.length); + else if (arg === '--dry-run') out.dryRun = true; + else if (arg === '--new-token') out.newToken = true; + else if (arg.startsWith('--to=')) out.to = arg.slice('--to='.length); + } + if (!out.sub) throw new Error('remint: --sub= is required'); + return out as RemintArgs; +} + +export async function runRemint(args: RemintArgs, deps: RemintDeps): Promise { + const license = await deps.getLicense(deps.db, args.sub); + if (!license) throw new Error(`remint: no license found for subscription ${args.sub}`); + if (license.revokedAt) { + throw new Error(`remint: license is revoked (revoked_at=${license.revokedAt.toISOString()}); refusing to resend`); + } + + let token = license.lastToken; + if (args.newToken) { + token = await deps.mintToken( + { + stripeCustomerId: license.stripeCustomerId, + tier: license.tier as 'developer-seat' | 'app-deployment', + seats: license.seats, + expiresAt: license.expiresAt, + }, + deps.privateKeyHex, + ); + await deps.updateLicenseToken(deps.db, license.id, token); + } + + const to = args.to ?? license.customerEmail; + const vars = { + tier: license.tier as 'developer-seat' | 'app-deployment', + seats: license.seats, + token, + expiresAt: license.expiresAt, + }; + + if (args.dryRun) { + return { sent: false, preview: renderLicenseEmail(vars) }; + } + + await deps.sendLicenseEmail({ + resendApiKey: deps.resendApiKey, + from: deps.emailFrom, + to, + vars, + }); + return { sent: true }; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const env = loadEnv(); + const db = createDb(env.DATABASE_URL); + try { + const result = await runRemint(args, { + db, + getLicense, + updateLicenseToken, + mintToken, + sendLicenseEmail, + resendApiKey: env.RESEND_API_KEY, + emailFrom: env.EMAIL_FROM, + privateKeyHex: env.LICENSE_SIGNING_PRIVATE_KEY_HEX, + }); + if (result.sent) { + console.log(`Sent to ${args.to ?? '(license email)'} for subscription ${args.sub}`); + } else if (result.preview) { + console.log('--- DRY RUN ---'); + console.log('Subject:', result.preview.subject); + console.log(result.preview.text); + } + } finally { + await db.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `cd /tmp/aaf-licensing && npx nx test minting-service --testPathPattern=remint` +Expected: PASS (11 tests). + +- [ ] **Step 5: Add Nx target** + +Edit `apps/minting-service/project.json`. Under `targets`, add: + +```json +"remint": { + "executor": "nx:run-commands", + "options": { + "command": "tsx scripts/remint.ts", + "cwd": "apps/minting-service" + } +} +``` + +- [ ] **Step 6: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/scripts/remint.ts apps/minting-service/scripts/remint.spec.ts apps/minting-service/project.json +git commit -m "feat(minting-service): add manual re-mint CLI" +``` + +--- + +## Phase I: Final verification and documentation + +### Task 25: Operator README + +**Files:** +- Create: `apps/minting-service/README.md` + +- [ ] **Step 1: Write README** + +Create `apps/minting-service/README.md`: + +````markdown +# @cacheplane/minting-service + +License minting service for Cacheplane. Receives Stripe webhooks, signs +Ed25519 license tokens via `@cacheplane/licensing`, persists them to +Postgres via `@cacheplane/db`, and emails them to customers via Resend. + +**Design spec:** `docs/superpowers/specs/2026-04-20-minting-service-design.md` + +## What this service does + +- Handles Stripe events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`. +- Mints a signed license token per active subscription. +- Emails the token to the customer. +- Stores license state keyed on `stripe_subscription_id`. + +## What this service does NOT do + +- No customer portal / self-service resend (run the CLI — see below). +- No pricing/checkout UI (handled on the website — Plan 3). +- No automated key rotation (requires library republish). + +## Local development + +1. Install Docker (for local Postgres) and the Stripe CLI. +2. From the repo root: + ```bash + docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16 + cp apps/minting-service/.env.example apps/minting-service/.env + # Edit .env with local values; for LICENSE_SIGNING_PRIVATE_KEY_HEX + # generate a keypair (see "Generating a signing key" below). + DATABASE_URL=postgres://postgres:dev@localhost:5432/postgres npx nx run db:db:migrate + cd apps/minting-service && vercel dev + ``` +3. In another terminal: + ```bash + stripe listen --forward-to localhost:3000/api/stripe-webhook + # Copy the printed whsec_... into apps/minting-service/.env as STRIPE_WEBHOOK_SECRET + ``` +4. Trigger events: + ```bash + stripe trigger checkout.session.completed + ``` + +## Generating a signing key + +```bash +node -e "import('@noble/ed25519').then(async (e) => { + const sk = e.utils.randomPrivateKey(); + const pk = await e.getPublicKeyAsync(sk); + console.log('priv (LICENSE_SIGNING_PRIVATE_KEY_HEX):', Buffer.from(sk).toString('hex')); + console.log('pub (LICENSE_PUBLIC_KEY in @cacheplane/licensing):', Buffer.from(pk).toString('hex')); +});" +``` + +Store the private key in the Vercel env as `LICENSE_SIGNING_PRIVATE_KEY_HEX` +marked "Sensitive". Back up to a password manager. The **public** key must be +baked into `libs/licensing/src/lib/license-public-key.generated.ts` and the +lib republished. + +## Environment variables + +All listed in `.env.example`. Validated at process start by `src/lib/env.ts`. +Missing/malformed vars throw with a descriptive message. + +## Deployment + +1. Ensure schema is up to date: + ```bash + DATABASE_URL= npx nx run db:db:migrate + ``` +2. Push. Vercel deploys from `main` automatically for production and per PR + for previews. +3. Smoke test preview: + ```bash + curl https://.vercel.app/api/health # {"ok":true} + stripe trigger checkout.session.completed # (against preview webhook endpoint) + ``` + +## Operator runbook + +### Re-mint a license + +```bash +nx run minting-service:remint --sub=sub_1234 [--dry-run] [--to=new@email.com] [--new-token] +``` + +- `--sub=` (required): which license to resend. +- `--dry-run`: print what would be sent; don't call Resend. +- `--to=`: override destination (use after an email bounce). +- `--new-token`: re-sign a fresh token (updates `last_token` + `issued_at`). + Default is to re-send the existing `last_token`. + +Revoked licenses are refused. + +### Look up a customer's license + +```bash +psql $DATABASE_URL -c "SELECT * FROM licenses WHERE customer_email = 'x@y.z'" +``` + +### Manually revoke + +```sql +UPDATE licenses SET revoked_at = now() WHERE stripe_subscription_id = 'sub_xxx'; +``` + +Prefer canceling the Stripe subscription — this bypasses the normal webhook +flow and won't un-revoke on a new subscription. + +### Un-revoke after accidental revoke + +```sql +UPDATE licenses SET revoked_at = NULL WHERE stripe_subscription_id = 'sub_xxx'; +``` + +Then `nx run minting-service:remint --sub=sub_xxx --new-token` to issue a +fresh token. + +### Retry a failed webhook + +1. In the Stripe dashboard → Developers → Webhooks, find the failed event `evt_xxx`. +2. Check if we recorded it: + ```sql + SELECT * FROM processed_events WHERE stripe_event_id = 'evt_xxx'; + ``` +3. If present: `DELETE FROM processed_events WHERE stripe_event_id = 'evt_xxx';` +4. Click "Resend" on the event in Stripe. + +### Rotate the signing key (manual, v1) + +Current design requires a library republish (no multi-key verification). +Steps: + +1. Generate new keypair (see "Generating a signing key"). +2. Update `libs/licensing/src/lib/license-public-key.generated.ts` with the new public key. +3. Republish `@cacheplane/licensing` (minor version bump). +4. Update `LICENSE_SIGNING_PRIVATE_KEY_HEX` in Vercel env. +5. Deploy minting service. +6. Batch-remint all active licenses: + ```bash + # Example: loop over all non-revoked subs and re-mint with fresh tokens + psql $DATABASE_URL -t -c "SELECT stripe_subscription_id FROM licenses WHERE revoked_at IS NULL" | \ + xargs -I{} nx run minting-service:remint --sub={} --new-token + ``` + +All existing tokens become unverifiable once customers upgrade the library. + +## Why this repo is public + +The private signing key lives only in Vercel env. Everything else — schema, +webhook logic, re-mint flow — is plumbing. Possession of the key is the only +thing that matters. Documenting the process openly is a transparency plus. +```` + +- [ ] **Step 2: Commit** + +```bash +cd /tmp/aaf-licensing +git add apps/minting-service/README.md +git commit -m "docs(minting-service): add operator runbook" +``` + +--- + +### Task 26: Final full-monorepo sanity sweep + +**Files:** (none modified — verification only) + +- [ ] **Step 1: Build everything** + +Run: `cd /tmp/aaf-licensing && npx nx run-many -t build` +Expected: PASS for all projects including `licensing`, `db`, `minting-service`. + +- [ ] **Step 2: Test everything** + +Run: `cd /tmp/aaf-licensing && npx nx run-many -t test` +Expected: PASS for all projects. Integration tests for `db` require Docker — if running in CI without Docker, tag those specs and use a separate target. + +- [ ] **Step 3: Lint everything** + +Run: `cd /tmp/aaf-licensing && npx nx run-many -t lint` +Expected: PASS. + +- [ ] **Step 4: Typecheck minting-service explicitly** + +Run: `cd /tmp/aaf-licensing && npx tsc --noEmit -p apps/minting-service/tsconfig.app.json` +Expected: PASS. + +- [ ] **Step 5: Manual preview smoke test** (requires a live Vercel preview deploy) + +1. Deploy the branch as a Vercel preview. +2. Configure test Stripe keys + preview Postgres + test Resend key in Vercel preview env. +3. Run `DATABASE_URL= npx nx run db:db:migrate` against the preview DB. +4. `stripe trigger checkout.session.completed` (against the preview webhook URL). +5. Verify: `psql $PREVIEW_DATABASE_URL -c "SELECT * FROM licenses ORDER BY created_at DESC LIMIT 1;"` returns a row. +6. Verify email arrives at a Resend-verified test address. +7. Paste the token into a sandbox app with `CACHEPLANE_LICENSE=`; verify `runLicenseCheck` reports active. +8. `stripe trigger customer.subscription.deleted`; verify `revoked_at` is set. +9. `nx run minting-service:remint --sub= --dry-run`; verify it refuses with "license is revoked". + +- [ ] **Step 6: Commit any remaining hygiene fixes** (only if something surfaced during sweep) + +```bash +cd /tmp/aaf-licensing +git status +# If clean, skip commit. +``` + +--- + +## Appendix: Open questions deferred to execution + +- **Testcontainers in CI.** Local tests require Docker. If CI does not expose a Docker daemon, tag the integration specs with a pattern (e.g. filename `*.integration.spec.ts`) and gate them behind an env var. Decide when wiring CI. +- **Vercel project creation.** A person with Vercel dashboard access must: (a) create the project pointing at `apps/minting-service`, (b) configure env vars for prod and preview, (c) register the Stripe webhook endpoint URLs (test + live) in Stripe dashboard and paste the signing secrets back into Vercel. This is a one-time operator task, not automatable from the plan. +- **Resend domain verification.** Before any live email sends, verify the sender domain in Resend. Out of scope for code but blocks prod rollout. From bb2d6255a4c3a83bca59a838de3ab45238bc2eac Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 12:50:56 -0700 Subject: [PATCH 22/51] feat(licensing): add signLicense for minting signed license tokens --- libs/licensing/src/index.ts | 1 + libs/licensing/src/lib/sign-license.spec.ts | 64 +++++++++++++++++++++ libs/licensing/src/lib/sign-license.ts | 26 +++++++++ 3 files changed, 91 insertions(+) create mode 100644 libs/licensing/src/lib/sign-license.spec.ts create mode 100644 libs/licensing/src/lib/sign-license.ts diff --git a/libs/licensing/src/index.ts b/libs/licensing/src/index.ts index 551faae06..a3382250e 100644 --- a/libs/licensing/src/index.ts +++ b/libs/licensing/src/index.ts @@ -14,5 +14,6 @@ export type { export { createTelemetryClient } from './lib/telemetry.js'; export type { RunLicenseCheckOptions } from './lib/run-license-check.js'; export { runLicenseCheck } from './lib/run-license-check.js'; +export { signLicense } from './lib/sign-license.js'; export { LICENSE_PUBLIC_KEY } from './lib/license-public-key.js'; export { inferNoncommercial } from './lib/infer-noncommercial.js'; diff --git a/libs/licensing/src/lib/sign-license.spec.ts b/libs/licensing/src/lib/sign-license.spec.ts new file mode 100644 index 000000000..2395d648c --- /dev/null +++ b/libs/licensing/src/lib/sign-license.spec.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import * as ed from '@noble/ed25519'; +import { signLicense } from './sign-license.js'; +import { verifyLicense } from './verify-license.js'; +import type { LicenseClaims } from './license-token.js'; + +describe('signLicense', () => { + it('produces a token that verifyLicense accepts with the matching public key', async () => { + const privateKey = ed.utils.randomPrivateKey(); + const publicKey = await ed.getPublicKeyAsync(privateKey); + const claims: LicenseClaims = { + sub: 'cus_test_123', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 1_800_000_000, + seats: 5, + }; + + const token = await signLicense(claims, privateKey); + const result = await verifyLicense(token, publicKey); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.claims).toEqual(claims); + } + }); + + it('produces a token with two base64url segments separated by a dot', async () => { + const privateKey = ed.utils.randomPrivateKey(); + const claims: LicenseClaims = { + sub: 'cus_abc', + tier: 'app-deployment', + iat: 1_700_000_000, + exp: 1_800_000_000, + seats: 1, + }; + + const token = await signLicense(claims, privateKey); + const parts = token.split('.'); + + expect(parts).toHaveLength(2); + expect(parts[0]).toMatch(/^[A-Za-z0-9_-]+$/); + expect(parts[1]).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('tokens signed with different keys fail verification against the wrong key', async () => { + const sk1 = ed.utils.randomPrivateKey(); + const sk2 = ed.utils.randomPrivateKey(); + const pk2 = await ed.getPublicKeyAsync(sk2); + const claims: LicenseClaims = { + sub: 'cus_x', + tier: 'developer-seat', + iat: 1_700_000_000, + exp: 1_800_000_000, + seats: 1, + }; + + const token = await signLicense(claims, sk1); + const result = await verifyLicense(token, pk2); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('tampered'); + }); +}); diff --git a/libs/licensing/src/lib/sign-license.ts b/libs/licensing/src/lib/sign-license.ts new file mode 100644 index 000000000..dad268daa --- /dev/null +++ b/libs/licensing/src/lib/sign-license.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import * as ed from '@noble/ed25519'; +import type { LicenseClaims } from './license-token.js'; + +function bytesToBase64Url(bytes: Uint8Array): string { + return Buffer.from(bytes) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * Sign license claims with an Ed25519 private key. + * Returns a compact token of the form `.`, + * compatible with {@link parseLicenseToken} and {@link verifyLicense}. + */ +export async function signLicense( + claims: LicenseClaims, + privateKey: Uint8Array, +): Promise { + const payloadJson = JSON.stringify(claims); + const payloadBytes = new TextEncoder().encode(payloadJson); + const signature = await ed.signAsync(payloadBytes, privateKey); + return `${bytesToBase64Url(payloadBytes)}.${bytesToBase64Url(signature)}`; +} From 205a39b8e9d98cf992cbedce6a01b9510225a1b6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 12:57:04 -0700 Subject: [PATCH 23/51] refactor(licensing): consolidate duplicate signLicense helper Remove test-only signLicense from testing/keypair.ts now that the production signLicense helper exists. verify-license.spec.ts now tests against the real signer. --- .../src/lib/run-license-check.spec.ts | 3 ++- libs/licensing/src/lib/testing/keypair.ts | 19 ------------------- libs/licensing/src/lib/verify-license.spec.ts | 3 ++- libs/licensing/src/testing.ts | 3 ++- 4 files changed, 6 insertions(+), 22 deletions(-) diff --git a/libs/licensing/src/lib/run-license-check.spec.ts b/libs/licensing/src/lib/run-license-check.spec.ts index 6c6774c73..76927c3db 100644 --- a/libs/licensing/src/lib/run-license-check.spec.ts +++ b/libs/licensing/src/lib/run-license-check.spec.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { runLicenseCheck, __resetRunLicenseCheckStateForTests } from './run-license-check'; import { __resetNagStateForTests } from './nag'; -import { generateKeyPair, signLicense, type DevKeyPair } from './testing/keypair'; +import { signLicense } from './sign-license'; +import { generateKeyPair, type DevKeyPair } from './testing/keypair'; import type { LicenseClaims } from './license-token'; const BASE: LicenseClaims = { diff --git a/libs/licensing/src/lib/testing/keypair.ts b/libs/licensing/src/lib/testing/keypair.ts index b2a023ada..834e7c725 100644 --- a/libs/licensing/src/lib/testing/keypair.ts +++ b/libs/licensing/src/lib/testing/keypair.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // TEST-ONLY utility: do not export from the package's public index. import * as ed from '@noble/ed25519'; -import type { LicenseClaims } from '../license-token'; export interface DevKeyPair { publicKey: Uint8Array; @@ -13,21 +12,3 @@ export async function generateKeyPair(): Promise { const publicKey = await ed.getPublicKeyAsync(privateKey); return { publicKey, privateKey }; } - -function b64url(bytes: Uint8Array): string { - return Buffer.from(bytes).toString('base64url'); -} - -/** - * Sign claims with the given private key and return a compact token - * `.`. Used by tests and by the - * dev-fixture generator; NOT used at runtime by the package. - */ -export async function signLicense( - claims: LicenseClaims, - privateKey: Uint8Array, -): Promise { - const payload = new TextEncoder().encode(JSON.stringify(claims)); - const sig = await ed.signAsync(payload, privateKey); - return `${b64url(payload)}.${b64url(sig)}`; -} diff --git a/libs/licensing/src/lib/verify-license.spec.ts b/libs/licensing/src/lib/verify-license.spec.ts index c1f8e4e83..0849a9f4f 100644 --- a/libs/licensing/src/lib/verify-license.spec.ts +++ b/libs/licensing/src/lib/verify-license.spec.ts @@ -1,7 +1,8 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { beforeAll, describe, it, expect } from 'vitest'; import { verifyLicense } from './verify-license'; -import { generateKeyPair, signLicense, type DevKeyPair } from './testing/keypair'; +import { signLicense } from './sign-license'; +import { generateKeyPair, type DevKeyPair } from './testing/keypair'; import type { LicenseClaims } from './license-token'; const BASE_CLAIMS: LicenseClaims = { diff --git a/libs/licensing/src/testing.ts b/libs/licensing/src/testing.ts index 81745d7ad..5bf26cfee 100644 --- a/libs/licensing/src/testing.ts +++ b/libs/licensing/src/testing.ts @@ -2,7 +2,8 @@ // Monorepo-internal test helpers. NOT part of the published package — // excluded from `tsconfig.lib.json` so nothing here ships in dist. // Downstream consumers cannot import `@cacheplane/licensing/testing`. -export { generateKeyPair, signLicense } from './lib/testing/keypair'; +export { generateKeyPair } from './lib/testing/keypair'; export type { DevKeyPair } from './lib/testing/keypair'; +export { signLicense } from './lib/sign-license'; export { __resetRunLicenseCheckStateForTests } from './lib/run-license-check'; export { __resetNagStateForTests } from './lib/nag'; From de34a3de7dbc18729c5296deaabb47181f02a6eb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 12:59:05 -0700 Subject: [PATCH 24/51] fix(licensing): repair fixtures.ts import after signLicense consolidation --- libs/licensing/src/lib/testing/fixtures.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/licensing/src/lib/testing/fixtures.ts b/libs/licensing/src/lib/testing/fixtures.ts index f00f8bd07..572499489 100644 --- a/libs/licensing/src/lib/testing/fixtures.ts +++ b/libs/licensing/src/lib/testing/fixtures.ts @@ -1,7 +1,8 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Shared test fixtures: helper to produce signed tokens against a freshly // generated keypair. Not exported from the package's public index. -import { signLicense, generateKeyPair, type DevKeyPair } from './keypair'; +import { signLicense } from '../sign-license'; +import { generateKeyPair, type DevKeyPair } from './keypair'; import type { LicenseClaims } from '../license-token'; export interface FixturePack { From eea577c3a16a4ca18a8a93d387e0493b62294043 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 13:03:16 -0700 Subject: [PATCH 25/51] feat(db): scaffold @cacheplane/db lib --- libs/db/package.json | 10 ++++++++++ libs/db/project.json | 23 +++++++++++++++++++++++ libs/db/src/index.ts | 3 +++ libs/db/tsconfig.json | 5 +++++ libs/db/tsconfig.lib.json | 10 ++++++++++ libs/db/vite.config.mts | 12 ++++++++++++ tsconfig.base.json | 1 + 7 files changed, 64 insertions(+) create mode 100644 libs/db/package.json create mode 100644 libs/db/project.json create mode 100644 libs/db/src/index.ts create mode 100644 libs/db/tsconfig.json create mode 100644 libs/db/tsconfig.lib.json create mode 100644 libs/db/vite.config.mts diff --git a/libs/db/package.json b/libs/db/package.json new file mode 100644 index 000000000..482d2b322 --- /dev/null +++ b/libs/db/package.json @@ -0,0 +1,10 @@ +{ + "name": "@cacheplane/db", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/libs/db/project.json b/libs/db/project.json new file mode 100644 index 000000000..68d7de4cc --- /dev/null +++ b/libs/db/project.json @@ -0,0 +1,23 @@ +{ + "name": "db", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/db/src", + "projectType": "library", + "tags": ["scope:shared", "type:lib"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/libs/db"], + "options": { + "outputPath": "dist/libs/db", + "main": "libs/db/src/index.ts", + "tsConfig": "libs/db/tsconfig.lib.json" + } + }, + "lint": { "executor": "@nx/eslint:lint" }, + "test": { + "executor": "@nx/vite:test", + "options": { "configFile": "libs/db/vite.config.mts" } + } + } +} diff --git a/libs/db/src/index.ts b/libs/db/src/index.ts new file mode 100644 index 000000000..0db5f7a34 --- /dev/null +++ b/libs/db/src/index.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Barrel populated in later tasks. +export {}; diff --git a/libs/db/tsconfig.json b/libs/db/tsconfig.json new file mode 100644 index 000000000..cf0cba0d6 --- /dev/null +++ b/libs/db/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/libs/db/tsconfig.lib.json b/libs/db/tsconfig.lib.json new file mode 100644 index 000000000..bf18bfd9a --- /dev/null +++ b/libs/db/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "emitDeclarationOnly": false + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/libs/db/vite.config.mts b/libs/db/vite.config.mts new file mode 100644 index 000000000..971c722be --- /dev/null +++ b/libs/db/vite.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + environment: 'node', + globals: true, + include: ['src/**/*.spec.ts'], + passWithNoTests: true, + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index b3d42e74a..3f3df86d8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,7 @@ "@cacheplane/chat": ["libs/chat/src/public-api.ts"], "@cacheplane/partial-json": ["libs/partial-json/src/index.ts"], "@cacheplane/a2ui": ["libs/a2ui/src/index.ts"], + "@cacheplane/db": ["libs/db/src/index.ts"], "@cacheplane/licensing": ["libs/licensing/src/index.ts"], "@cacheplane/licensing/testing": ["libs/licensing/src/testing.ts"], "@cacheplane/example-layouts": ["libs/example-layouts/src/public-api.ts"] From 15dcbd88872e461fec2309aeb2cead4810212b89 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 13:07:40 -0700 Subject: [PATCH 26/51] feat(db): add Drizzle client factory --- libs/db/package.json | 4 + libs/db/src/lib/client.spec.ts | 16 + libs/db/src/lib/client.ts | 22 + libs/db/src/lib/schema/index.ts | 3 + package-lock.json | 1096 +++++++++++++++++++++++++++++++ package.json | 3 + 6 files changed, 1144 insertions(+) create mode 100644 libs/db/src/lib/client.spec.ts create mode 100644 libs/db/src/lib/client.ts create mode 100644 libs/db/src/lib/schema/index.ts diff --git a/libs/db/package.json b/libs/db/package.json index 482d2b322..6d6cac0e2 100644 --- a/libs/db/package.json +++ b/libs/db/package.json @@ -6,5 +6,9 @@ "publishConfig": { "access": "public", "provenance": true + }, + "peerDependencies": { + "drizzle-orm": "^0.45.0", + "postgres": "^3.4.0" } } diff --git a/libs/db/src/lib/client.spec.ts b/libs/db/src/lib/client.spec.ts new file mode 100644 index 000000000..9ac610d1d --- /dev/null +++ b/libs/db/src/lib/client.spec.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { createDb } from './client.js'; + +describe('createDb', () => { + it('returns an object with a query builder and a connection closer', () => { + const db = createDb('postgres://fake@localhost:5432/fake'); + expect(db).toBeDefined(); + expect(typeof db.close).toBe('function'); + // Close immediately — we're not actually connecting. + return db.close(); + }); + + it('throws if the connection string is empty', () => { + expect(() => createDb('')).toThrow(/connection string/i); + }); +}); diff --git a/libs/db/src/lib/client.ts b/libs/db/src/lib/client.ts new file mode 100644 index 000000000..e198b53e1 --- /dev/null +++ b/libs/db/src/lib/client.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema/index.js'; + +export type Db = ReturnType> & { + close: () => Promise; +}; + +/** + * Create a Drizzle client bound to the given Postgres connection string. + * Caller is responsible for calling `close()` during shutdown. + */ +export function createDb(connectionString: string): Db { + if (!connectionString) { + throw new Error('createDb: connection string is required'); + } + const sql = postgres(connectionString, { prepare: false }); + const db = drizzle(sql, { schema }) as Db; + db.close = () => sql.end(); + return db; +} diff --git a/libs/db/src/lib/schema/index.ts b/libs/db/src/lib/schema/index.ts new file mode 100644 index 000000000..5831432d0 --- /dev/null +++ b/libs/db/src/lib/schema/index.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Populated in Tasks 4 & 5. +export {}; diff --git a/package-lock.json b/package-lock.json index dd7a8c22f..5f6c0383f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "stream-resource", "version": "0.0.0", + "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "workspaces": [ "packages/*", @@ -23,9 +24,11 @@ "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1", "@noble/ed25519": "^2.3.0", + "drizzle-orm": "^0.45.2", "framer-motion": "^12.38.0", "next": "~16.1.6", "next-mdx-remote": "^6.0.0", + "postgres": "^3.4.9", "react": "^19.0.0", "react-dom": "^19.0.0", "rehype-pretty-code": "^0.14.3", @@ -66,6 +69,7 @@ "@vitest/coverage-v8": "^4.1.0", "angular-eslint": "^21.0.1", "autoprefixer": "^10.4.27", + "drizzle-kit": "^0.31.10", "eslint": "^9.8.0", "eslint-config-prettier": "^10.0.0", "jsdom": "^29.0.0", @@ -6962,6 +6966,13 @@ "node": ">=14.17.0" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -6993,6 +7004,453 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -23725,6 +24183,631 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -33858,6 +34941,19 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", diff --git a/package.json b/package.json index d44bc0191..c4590560c 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@vitest/coverage-v8": "^4.1.0", "angular-eslint": "^21.0.1", "autoprefixer": "^10.4.27", + "drizzle-kit": "^0.31.10", "eslint": "^9.8.0", "eslint-config-prettier": "^10.0.0", "jsdom": "^29.0.0", @@ -80,9 +81,11 @@ "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1", "@noble/ed25519": "^2.3.0", + "drizzle-orm": "^0.45.2", "framer-motion": "^12.38.0", "next": "~16.1.6", "next-mdx-remote": "^6.0.0", + "postgres": "^3.4.9", "react": "^19.0.0", "react-dom": "^19.0.0", "rehype-pretty-code": "^0.14.3", From 2f1e7a70a3adcb31532533036d04b009c3afb0ac Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 13:10:30 -0700 Subject: [PATCH 27/51] docs(db): explain why prepare:false is required --- libs/db/src/lib/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/db/src/lib/client.ts b/libs/db/src/lib/client.ts index e198b53e1..13950310d 100644 --- a/libs/db/src/lib/client.ts +++ b/libs/db/src/lib/client.ts @@ -15,6 +15,7 @@ export function createDb(connectionString: string): Db { if (!connectionString) { throw new Error('createDb: connection string is required'); } + // prepare: false — required by Vercel Postgres / PgBouncer transaction pooling. const sql = postgres(connectionString, { prepare: false }); const db = drizzle(sql, { schema }) as Db; db.close = () => sql.end(); From aea37a5f2de6cb72840cca8f0e4cd2d213357308 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 13:11:29 -0700 Subject: [PATCH 28/51] feat(db): add licenses table schema --- libs/db/src/lib/schema/index.ts | 3 +-- libs/db/src/lib/schema/licenses.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 libs/db/src/lib/schema/licenses.ts diff --git a/libs/db/src/lib/schema/index.ts b/libs/db/src/lib/schema/index.ts index 5831432d0..5f36afa4a 100644 --- a/libs/db/src/lib/schema/index.ts +++ b/libs/db/src/lib/schema/index.ts @@ -1,3 +1,2 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -// Populated in Tasks 4 & 5. -export {}; +export * from './licenses.js'; diff --git a/libs/db/src/lib/schema/licenses.ts b/libs/db/src/lib/schema/licenses.ts new file mode 100644 index 000000000..bdb74ae87 --- /dev/null +++ b/libs/db/src/lib/schema/licenses.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { pgTable, uuid, text, integer, timestamp, index } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; + +export const licenses = pgTable( + 'licenses', + { + id: uuid('id').primaryKey().default(sql`gen_random_uuid()`), + stripeCustomerId: text('stripe_customer_id').notNull(), + stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(), + customerEmail: text('customer_email').notNull(), + tier: text('tier').notNull(), + seats: integer('seats').notNull(), + issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + lastToken: text('last_token').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + customerIdx: index('licenses_customer_idx').on(t.stripeCustomerId), + emailIdx: index('licenses_email_idx').on(t.customerEmail), + }), +); + +export type License = typeof licenses.$inferSelect; +export type NewLicense = typeof licenses.$inferInsert; From 3efcc0c72e793e9e4f5ad4fdc6da9e909ea50bcd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 13:12:57 -0700 Subject: [PATCH 29/51] chore: migrate @nx/vite:test to @nx/vitest:test The @nx/vite:test executor is deprecated and will be removed in Nx 23. @nx/vitest:test is a drop-in replacement (same configFile option). Affects 13 project.json files across libs, apps, and e2e. --- apps/cockpit/project.json | 2 +- e2e/agent-e2e/project.json | 2 +- libs/a2ui/project.json | 2 +- libs/agent/project.json | 2 +- libs/chat/project.json | 2 +- libs/cockpit-registry/project.json | 2 +- libs/db/project.json | 2 +- libs/design-tokens/project.json | 2 +- libs/example-layouts/project.json | 2 +- libs/licensing/project.json | 2 +- libs/partial-json/project.json | 2 +- libs/render/project.json | 2 +- libs/ui-react/project.json | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/cockpit/project.json b/apps/cockpit/project.json index f9272999d..3830f04ea 100644 --- a/apps/cockpit/project.json +++ b/apps/cockpit/project.json @@ -41,7 +41,7 @@ } }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "apps/cockpit/vite.config.mts" } diff --git a/e2e/agent-e2e/project.json b/e2e/agent-e2e/project.json index 311fa2ac0..aee11172f 100644 --- a/e2e/agent-e2e/project.json +++ b/e2e/agent-e2e/project.json @@ -5,7 +5,7 @@ "sourceRoot": "e2e/agent-e2e/src", "targets": { "e2e": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "e2e/agent-e2e/vite.config.mts" } diff --git a/libs/a2ui/project.json b/libs/a2ui/project.json index 1efd89a15..a040a3565 100644 --- a/libs/a2ui/project.json +++ b/libs/a2ui/project.json @@ -16,7 +16,7 @@ }, "lint": { "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/a2ui/vite.config.mts" } } } diff --git a/libs/agent/project.json b/libs/agent/project.json index daa0a11e8..6f9baf8d5 100644 --- a/libs/agent/project.json +++ b/libs/agent/project.json @@ -37,7 +37,7 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/agent/vite.config.mts" } diff --git a/libs/chat/project.json b/libs/chat/project.json index c37768a5e..85502c2cf 100644 --- a/libs/chat/project.json +++ b/libs/chat/project.json @@ -37,7 +37,7 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/chat/vite.config.mts" } diff --git a/libs/cockpit-registry/project.json b/libs/cockpit-registry/project.json index 66bf81ed5..bc725a198 100644 --- a/libs/cockpit-registry/project.json +++ b/libs/cockpit-registry/project.json @@ -18,7 +18,7 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/cockpit-registry/vite.config.mts" } diff --git a/libs/db/project.json b/libs/db/project.json index 68d7de4cc..65869b5f9 100644 --- a/libs/db/project.json +++ b/libs/db/project.json @@ -16,7 +16,7 @@ }, "lint": { "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/db/vite.config.mts" } } } diff --git a/libs/design-tokens/project.json b/libs/design-tokens/project.json index 51f7f8796..3c155bf83 100644 --- a/libs/design-tokens/project.json +++ b/libs/design-tokens/project.json @@ -18,7 +18,7 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/design-tokens/vite.config.mts" } diff --git a/libs/example-layouts/project.json b/libs/example-layouts/project.json index 8c994fcf5..bd9c21142 100644 --- a/libs/example-layouts/project.json +++ b/libs/example-layouts/project.json @@ -25,7 +25,7 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/example-layouts/vite.config.mts" } diff --git a/libs/licensing/project.json b/libs/licensing/project.json index 6fe6fa91b..579562cf9 100644 --- a/libs/licensing/project.json +++ b/libs/licensing/project.json @@ -23,7 +23,7 @@ }, "lint": { "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "dependsOn": ["prebuild"], "options": { "configFile": "libs/licensing/vite.config.mts" } } diff --git a/libs/partial-json/project.json b/libs/partial-json/project.json index 009be76c2..1a796319a 100644 --- a/libs/partial-json/project.json +++ b/libs/partial-json/project.json @@ -18,7 +18,7 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/partial-json/vite.config.mts" } diff --git a/libs/render/project.json b/libs/render/project.json index 53a9b53f8..4ad1867ca 100644 --- a/libs/render/project.json +++ b/libs/render/project.json @@ -37,7 +37,7 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/render/vite.config.mts" } diff --git a/libs/ui-react/project.json b/libs/ui-react/project.json index 4659a6561..798ffbc07 100644 --- a/libs/ui-react/project.json +++ b/libs/ui-react/project.json @@ -18,7 +18,7 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/vite:test", + "executor": "@nx/vitest:test", "options": { "configFile": "libs/ui-react/vite.config.mts" } From 3d46420dbe230528906e3a14c598a7327df9616e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 13:14:36 -0700 Subject: [PATCH 30/51] feat(db): add processed_events table schema --- libs/db/src/lib/schema/index.ts | 1 + libs/db/src/lib/schema/processed-events.ts | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 libs/db/src/lib/schema/processed-events.ts diff --git a/libs/db/src/lib/schema/index.ts b/libs/db/src/lib/schema/index.ts index 5f36afa4a..28916a88d 100644 --- a/libs/db/src/lib/schema/index.ts +++ b/libs/db/src/lib/schema/index.ts @@ -1,2 +1,3 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 export * from './licenses.js'; +export * from './processed-events.js'; diff --git a/libs/db/src/lib/schema/processed-events.ts b/libs/db/src/lib/schema/processed-events.ts new file mode 100644 index 000000000..f67879ab1 --- /dev/null +++ b/libs/db/src/lib/schema/processed-events.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'; + +export const processedEvents = pgTable('processed_events', { + stripeEventId: text('stripe_event_id').primaryKey(), + eventType: text('event_type').notNull(), + processedAt: timestamp('processed_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export type ProcessedEvent = typeof processedEvents.$inferSelect; +export type NewProcessedEvent = typeof processedEvents.$inferInsert; From 9dc0ad3e45f09988a3ea72961856241b36e3051e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 13:16:19 -0700 Subject: [PATCH 31/51] feat(db): configure drizzle-kit and generate initial migration --- libs/db/drizzle.config.ts | 10 ++ libs/db/drizzle/0000_init.sql | 24 ++++ libs/db/drizzle/meta/0000_snapshot.json | 179 ++++++++++++++++++++++++ libs/db/drizzle/meta/_journal.json | 13 ++ libs/db/project.json | 14 ++ 5 files changed, 240 insertions(+) create mode 100644 libs/db/drizzle.config.ts create mode 100644 libs/db/drizzle/0000_init.sql create mode 100644 libs/db/drizzle/meta/0000_snapshot.json create mode 100644 libs/db/drizzle/meta/_journal.json diff --git a/libs/db/drizzle.config.ts b/libs/db/drizzle.config.ts new file mode 100644 index 000000000..b3561ca18 --- /dev/null +++ b/libs/db/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/lib/schema/index.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env['DATABASE_URL'] ?? '', + }, +}); diff --git a/libs/db/drizzle/0000_init.sql b/libs/db/drizzle/0000_init.sql new file mode 100644 index 000000000..db55d368b --- /dev/null +++ b/libs/db/drizzle/0000_init.sql @@ -0,0 +1,24 @@ +CREATE TABLE "licenses" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "stripe_customer_id" text NOT NULL, + "stripe_subscription_id" text NOT NULL, + "customer_email" text NOT NULL, + "tier" text NOT NULL, + "seats" integer NOT NULL, + "issued_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + "last_token" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "licenses_stripe_subscription_id_unique" UNIQUE("stripe_subscription_id") +); +--> statement-breakpoint +CREATE TABLE "processed_events" ( + "stripe_event_id" text PRIMARY KEY NOT NULL, + "event_type" text NOT NULL, + "processed_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "licenses_customer_idx" ON "licenses" USING btree ("stripe_customer_id");--> statement-breakpoint +CREATE INDEX "licenses_email_idx" ON "licenses" USING btree ("customer_email"); \ No newline at end of file diff --git a/libs/db/drizzle/meta/0000_snapshot.json b/libs/db/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..327a2d1e2 --- /dev/null +++ b/libs/db/drizzle/meta/0000_snapshot.json @@ -0,0 +1,179 @@ +{ + "id": "7c7f6a73-19cd-4f25-a6f9-a84b4f235c8a", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.licenses": { + "name": "licenses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_email": { + "name": "customer_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_token": { + "name": "last_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "licenses_customer_idx": { + "name": "licenses_customer_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "licenses_email_idx": { + "name": "licenses_email_idx", + "columns": [ + { + "expression": "customer_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "licenses_stripe_subscription_id_unique": { + "name": "licenses_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_events": { + "name": "processed_events", + "schema": "", + "columns": { + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/libs/db/drizzle/meta/_journal.json b/libs/db/drizzle/meta/_journal.json new file mode 100644 index 000000000..6a845f975 --- /dev/null +++ b/libs/db/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1776716165218, + "tag": "0000_init", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/libs/db/project.json b/libs/db/project.json index 65869b5f9..bcb310fef 100644 --- a/libs/db/project.json +++ b/libs/db/project.json @@ -18,6 +18,20 @@ "test": { "executor": "@nx/vitest:test", "options": { "configFile": "libs/db/vite.config.mts" } + }, + "db:generate": { + "executor": "nx:run-commands", + "options": { + "command": "drizzle-kit generate", + "cwd": "libs/db" + } + }, + "db:migrate": { + "executor": "nx:run-commands", + "options": { + "command": "drizzle-kit migrate", + "cwd": "libs/db" + } } } } From 42c5a44d97b9dc8fa1ff8473096c8e483a7b2be9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:02:35 -0700 Subject: [PATCH 32/51] feat(db): add testcontainers-based integration test helpers --- libs/db/src/lib/queries/test-helpers.ts | 33 + libs/db/vite.config.mts | 1 + package-lock.json | 1242 +++++++++++++++++++++++ package.json | 2 + 4 files changed, 1278 insertions(+) create mode 100644 libs/db/src/lib/queries/test-helpers.ts diff --git a/libs/db/src/lib/queries/test-helpers.ts b/libs/db/src/lib/queries/test-helpers.ts new file mode 100644 index 000000000..0ac967b95 --- /dev/null +++ b/libs/db/src/lib/queries/test-helpers.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import * as path from 'node:path'; +import * as schema from '../schema/index.js'; + +export interface TestDb { + db: ReturnType>; + cleanup: () => Promise; +} + +/** + * Spin up a disposable Postgres container, run migrations, and return a + * Drizzle client plus a cleanup function. Call `cleanup` in afterAll. + */ +export async function startTestDb(): Promise { + const container: StartedPostgreSqlContainer = await new PostgreSqlContainer('postgres:16').start(); + const sql = postgres(container.getConnectionUri(), { prepare: false }); + const db = drizzle(sql, { schema }); + + const migrationsFolder = path.resolve(__dirname, '../../../drizzle'); + await migrate(db, { migrationsFolder }); + + return { + db, + cleanup: async () => { + await sql.end(); + await container.stop(); + }, + }; +} diff --git a/libs/db/vite.config.mts b/libs/db/vite.config.mts index 971c722be..18a6e191c 100644 --- a/libs/db/vite.config.mts +++ b/libs/db/vite.config.mts @@ -8,5 +8,6 @@ export default defineConfig({ globals: true, include: ['src/**/*.spec.ts'], passWithNoTests: true, + testTimeout: 60_000, }, }); diff --git a/package-lock.json b/package-lock.json index 5f6c0383f..8e93d0b4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "@swc/core": "1.15.8", "@swc/helpers": "0.5.18", "@tailwindcss/postcss": "^4.2.2", + "@testcontainers/postgresql": "^11.14.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/utils": "^8.40.0", @@ -83,6 +84,7 @@ "remark-gfm": "^4.0.1", "resend": "^6.10.0", "tailwindcss": "^4.2.2", + "testcontainers": "^11.14.0", "tslib": "^2.3.0", "tsx": "^4.21.0", "typedoc": "^0.28.17", @@ -6739,6 +6741,13 @@ "node": ">=6.9.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -8191,6 +8200,58 @@ "@types/hast": "^3.0.4" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@harperfast/extended-iterable": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", @@ -9147,6 +9208,109 @@ "node": "20 || >=22" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -9316,6 +9480,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@json-render/core": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@json-render/core/-/core-0.16.0.tgz", @@ -9769,6 +9944,16 @@ "tslib": "2" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, "node_modules/@langchain/core": { "version": "1.1.33", "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.33.tgz", @@ -16264,6 +16449,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -16280,6 +16476,80 @@ "node": ">=18" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@puppeteer/browsers": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", @@ -19178,6 +19448,16 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@testcontainers/postgresql": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.14.0.tgz", + "integrity": "sha512-wYbJn8GRTj8qfqzfVubxioYWlHJU/ImIjuzPwyy9C5Qfo6g3GLduPZAj+BifvqTZjgT3gd4gFVLCPhBji7dc1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "testcontainers": "^11.14.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -19359,6 +19639,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz", + "integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -19678,6 +19981,43 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz", + "integrity": "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -21465,6 +21805,213 @@ ], "license": "MIT" }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -21605,6 +22152,13 @@ "dev": true, "license": "MIT" }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -22362,6 +22916,16 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -22378,6 +22942,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -23069,6 +23643,65 @@ "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -23349,6 +23982,90 @@ "node": ">= 6" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", @@ -24084,6 +24801,99 @@ "node": ">=6" } }, + "node_modules/docker-compose": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.4.2.tgz", + "integrity": "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.10.tgz", + "integrity": "sha512-8L/P9JynLBiG7/coiA4FlQXegHltRqS0a+KqI44P1zgQh8QLHTg7FKOwhkBgSJwZTeHsq30WRoVFLuwkfK0YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/dockerode/node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/dockerode/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/dockerode/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -24868,6 +25678,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -26430,6 +27247,36 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -26863,6 +27710,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -28681,6 +29541,22 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -29527,6 +30403,52 @@ "shell-quote": "^1.8.3" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/less": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/less/-/less-4.6.4.tgz", @@ -30430,6 +31352,13 @@ "node": ">=8.0" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/long-timeout": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", @@ -32084,6 +33013,13 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/motion-dom": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", @@ -32182,6 +33118,14 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -33424,6 +34368,13 @@ "node": ">= 14" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pacote": { "version": "21.0.4", "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.4.tgz", @@ -35089,6 +36040,62 @@ "node": ">= 4" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/properties-reader": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-3.0.1.tgz", + "integrity": "sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "mkdirp": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" + } + }, + "node_modules/properties-reader/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -35099,6 +36106,31 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protobufjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -35481,6 +36513,29 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -37686,6 +38741,13 @@ "wbuf": "^1.7.3" } }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -37703,6 +38765,46 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssh-remote-port-forward": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", + "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^0.5.48", + "ssh2": "^1.4.0" + } + }, + "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -37906,6 +39008,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -37933,6 +39051,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -38427,6 +39559,40 @@ "source-map": "^0.6.0" } }, + "node_modules/testcontainers": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.14.0.tgz", + "integrity": "sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^4.0.1", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.4.3", + "docker-compose": "^1.4.2", + "dockerode": "^4.0.10", + "get-port": "^7.2.0", + "proper-lockfile": "^4.1.2", + "properties-reader": "^3.0.1", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.1.2", + "tmp": "^0.2.5", + "undici": "^7.24.5" + } + }, + "node_modules/testcontainers/node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -40580,6 +41746,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -40791,6 +41976,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", diff --git a/package.json b/package.json index c4590560c..b9d401d3e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@swc/core": "1.15.8", "@swc/helpers": "0.5.18", "@tailwindcss/postcss": "^4.2.2", + "@testcontainers/postgresql": "^11.14.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/utils": "^8.40.0", @@ -58,6 +59,7 @@ "remark-gfm": "^4.0.1", "resend": "^6.10.0", "tailwindcss": "^4.2.2", + "testcontainers": "^11.14.0", "tslib": "^2.3.0", "tsx": "^4.21.0", "typedoc": "^0.28.17", From 00c0cb73e1bc180ff1be67a19121399c1107cf1e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:31:10 -0700 Subject: [PATCH 33/51] feat(db): add processed-events queries with idempotency --- libs/db/src/index.ts | 6 ++- .../src/lib/queries/processed-events.spec.ts | 41 +++++++++++++++++++ libs/db/src/lib/queries/processed-events.ts | 29 +++++++++++++ libs/db/src/lib/queries/test-helpers.ts | 4 +- libs/db/vite.config.mts | 1 + 5 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 libs/db/src/lib/queries/processed-events.spec.ts create mode 100644 libs/db/src/lib/queries/processed-events.ts diff --git a/libs/db/src/index.ts b/libs/db/src/index.ts index 0db5f7a34..445c16925 100644 --- a/libs/db/src/index.ts +++ b/libs/db/src/index.ts @@ -1,3 +1,5 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -// Barrel populated in later tasks. -export {}; +export { createDb } from './lib/client.js'; +export type { Db } from './lib/client.js'; +export * from './lib/schema/index.js'; +export { markEventProcessed, deleteProcessedEvent } from './lib/queries/processed-events.js'; diff --git a/libs/db/src/lib/queries/processed-events.spec.ts b/libs/db/src/lib/queries/processed-events.spec.ts new file mode 100644 index 000000000..3da34fd84 --- /dev/null +++ b/libs/db/src/lib/queries/processed-events.spec.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { markEventProcessed, deleteProcessedEvent } from './processed-events.js'; +import { startTestDb, type TestDb } from './test-helpers.js'; + +describe('processed-events queries', () => { + let testDb: TestDb; + + beforeAll(async () => { + testDb = await startTestDb(); + }); + + afterAll(async () => { + await testDb.cleanup(); + }); + + describe('markEventProcessed', () => { + it('returns true on first insert of an event id', async () => { + const result = await markEventProcessed(testDb.db, 'evt_first', 'checkout.session.completed'); + expect(result).toBe(true); + }); + + it('returns false on subsequent inserts of the same event id (idempotent)', async () => { + await markEventProcessed(testDb.db, 'evt_dup', 'checkout.session.completed'); + const result = await markEventProcessed(testDb.db, 'evt_dup', 'checkout.session.completed'); + expect(result).toBe(false); + }); + }); + + describe('deleteProcessedEvent', () => { + it('allows an event id to be reprocessed after deletion', async () => { + await markEventProcessed(testDb.db, 'evt_retry', 'customer.subscription.updated'); + await deleteProcessedEvent(testDb.db, 'evt_retry'); + const result = await markEventProcessed(testDb.db, 'evt_retry', 'customer.subscription.updated'); + expect(result).toBe(true); + }); + + it('is a no-op when the event id does not exist', async () => { + await expect(deleteProcessedEvent(testDb.db, 'evt_does_not_exist')).resolves.toBeUndefined(); + }); + }); +}); diff --git a/libs/db/src/lib/queries/processed-events.ts b/libs/db/src/lib/queries/processed-events.ts new file mode 100644 index 000000000..910e3529f --- /dev/null +++ b/libs/db/src/lib/queries/processed-events.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { eq } from 'drizzle-orm'; +import type { Db } from '../client.js'; +import { processedEvents } from '../schema/processed-events.js'; + +/** + * Insert an event id. Returns `true` if this was the first time we saw it, + * `false` if it was already recorded (Stripe retry). + */ +export async function markEventProcessed( + db: Db, + stripeEventId: string, + eventType: string, +): Promise { + const rows = await db + .insert(processedEvents) + .values({ stripeEventId, eventType }) + .onConflictDoNothing({ target: processedEvents.stripeEventId }) + .returning({ id: processedEvents.stripeEventId }); + return rows.length > 0; +} + +/** + * Remove a processed-event marker. Used for compensating deletes when a + * handler fails after the marker was written. + */ +export async function deleteProcessedEvent(db: Db, stripeEventId: string): Promise { + await db.delete(processedEvents).where(eq(processedEvents.stripeEventId, stripeEventId)); +} diff --git a/libs/db/src/lib/queries/test-helpers.ts b/libs/db/src/lib/queries/test-helpers.ts index 0ac967b95..1b87e200e 100644 --- a/libs/db/src/lib/queries/test-helpers.ts +++ b/libs/db/src/lib/queries/test-helpers.ts @@ -16,7 +16,9 @@ export interface TestDb { * Drizzle client plus a cleanup function. Call `cleanup` in afterAll. */ export async function startTestDb(): Promise { - const container: StartedPostgreSqlContainer = await new PostgreSqlContainer('postgres:16').start(); + // Ryuk (the reaper container) sometimes hangs on macOS; cleanup() handles teardown. + process.env['TESTCONTAINERS_RYUK_DISABLED'] = 'true'; + const container: StartedPostgreSqlContainer = await new PostgreSqlContainer('postgres:16-alpine').start(); const sql = postgres(container.getConnectionUri(), { prepare: false }); const db = drizzle(sql, { schema }); diff --git a/libs/db/vite.config.mts b/libs/db/vite.config.mts index 18a6e191c..5afdd0cbe 100644 --- a/libs/db/vite.config.mts +++ b/libs/db/vite.config.mts @@ -9,5 +9,6 @@ export default defineConfig({ include: ['src/**/*.spec.ts'], passWithNoTests: true, testTimeout: 60_000, + hookTimeout: 120_000, }, }); From 97f239176363ad804872f5528b1036308d5004e9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:35:36 -0700 Subject: [PATCH 34/51] feat(db): add license queries (upsert, get, revoke, updateToken, byEmail) --- libs/db/src/index.ts | 8 ++ libs/db/src/lib/queries/licenses.spec.ts | 98 ++++++++++++++++++++++++ libs/db/src/lib/queries/licenses.ts | 65 ++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 libs/db/src/lib/queries/licenses.spec.ts create mode 100644 libs/db/src/lib/queries/licenses.ts diff --git a/libs/db/src/index.ts b/libs/db/src/index.ts index 445c16925..641df0234 100644 --- a/libs/db/src/index.ts +++ b/libs/db/src/index.ts @@ -3,3 +3,11 @@ export { createDb } from './lib/client.js'; export type { Db } from './lib/client.js'; export * from './lib/schema/index.js'; export { markEventProcessed, deleteProcessedEvent } from './lib/queries/processed-events.js'; +export { + upsertLicense, + getLicense, + getLicensesByCustomerEmail, + revokeLicense, + updateLicenseToken, +} from './lib/queries/licenses.js'; +export type { UpsertLicenseInput } from './lib/queries/licenses.js'; diff --git a/libs/db/src/lib/queries/licenses.spec.ts b/libs/db/src/lib/queries/licenses.spec.ts new file mode 100644 index 000000000..917131fd6 --- /dev/null +++ b/libs/db/src/lib/queries/licenses.spec.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + upsertLicense, + getLicense, + getLicensesByCustomerEmail, + revokeLicense, + updateLicenseToken, +} from './licenses.js'; +import { startTestDb, type TestDb } from './test-helpers.js'; + +const base = { + stripeCustomerId: 'cus_1', + stripeSubscriptionId: 'sub_1', + customerEmail: 'a@example.com', + tier: 'developer-seat' as const, + seats: 3, + expiresAt: new Date('2027-01-01T00:00:00Z'), + lastToken: 'token-v1', +}; + +describe('licenses queries', () => { + let testDb: TestDb; + + beforeAll(async () => { + testDb = await startTestDb(); + }); + + afterAll(async () => { + await testDb.cleanup(); + }); + + describe('upsertLicense', () => { + it('inserts a new row keyed on stripe_subscription_id', async () => { + const row = await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_insert' }); + expect(row.stripeSubscriptionId).toBe('sub_insert'); + expect(row.seats).toBe(3); + expect(row.id).toBeDefined(); + }); + + it('updates an existing row on repeat sub id', async () => { + await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_update', seats: 2 }); + const updated = await upsertLicense(testDb.db, { + ...base, + stripeSubscriptionId: 'sub_update', + seats: 7, + lastToken: 'token-v2', + }); + expect(updated.seats).toBe(7); + expect(updated.lastToken).toBe('token-v2'); + }); + }); + + describe('getLicense', () => { + it('returns the row when present', async () => { + await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_get' }); + const found = await getLicense(testDb.db, 'sub_get'); + expect(found?.stripeSubscriptionId).toBe('sub_get'); + }); + + it('returns null when not found', async () => { + const found = await getLicense(testDb.db, 'sub_missing'); + expect(found).toBeNull(); + }); + }); + + describe('getLicensesByCustomerEmail', () => { + it('returns all rows matching the email', async () => { + await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_e1', customerEmail: 'multi@example.com' }); + await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_e2', customerEmail: 'multi@example.com' }); + const rows = await getLicensesByCustomerEmail(testDb.db, 'multi@example.com'); + expect(rows.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('revokeLicense', () => { + it('sets revoked_at to now', async () => { + await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_revoke' }); + const revoked = await revokeLicense(testDb.db, 'sub_revoke'); + expect(revoked?.revokedAt).toBeInstanceOf(Date); + }); + + it('returns null for unknown subscription', async () => { + const result = await revokeLicense(testDb.db, 'sub_missing_revoke'); + expect(result).toBeNull(); + }); + }); + + describe('updateLicenseToken', () => { + it('replaces last_token and bumps issued_at', async () => { + const inserted = await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_token' }); + const before = inserted.issuedAt; + await new Promise((r) => setTimeout(r, 10)); + const updated = await updateLicenseToken(testDb.db, inserted.id, 'token-v99'); + expect(updated.lastToken).toBe('token-v99'); + expect(updated.issuedAt.getTime()).toBeGreaterThan(before.getTime()); + }); + }); +}); diff --git a/libs/db/src/lib/queries/licenses.ts b/libs/db/src/lib/queries/licenses.ts new file mode 100644 index 000000000..3fa915d77 --- /dev/null +++ b/libs/db/src/lib/queries/licenses.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { eq, sql } from 'drizzle-orm'; +import type { Db } from '../client.js'; +import { licenses, type License, type NewLicense } from '../schema/licenses.js'; + +export type UpsertLicenseInput = Omit & { + id?: string; +}; + +/** + * Insert a license or update the existing row keyed on stripe_subscription_id. + * Bumps issued_at and updated_at on every call. + */ +export async function upsertLicense(db: Db, input: UpsertLicenseInput): Promise { + const now = new Date(); + const rows = await db + .insert(licenses) + .values({ ...input, issuedAt: now, updatedAt: now }) + .onConflictDoUpdate({ + target: licenses.stripeSubscriptionId, + set: { + customerEmail: input.customerEmail, + tier: input.tier, + seats: input.seats, + expiresAt: input.expiresAt, + lastToken: input.lastToken, + issuedAt: now, + updatedAt: now, + }, + }) + .returning(); + return rows[0]; +} + +export async function getLicense(db: Db, stripeSubscriptionId: string): Promise { + const rows = await db + .select() + .from(licenses) + .where(eq(licenses.stripeSubscriptionId, stripeSubscriptionId)) + .limit(1); + return rows[0] ?? null; +} + +export async function getLicensesByCustomerEmail(db: Db, email: string): Promise { + return db.select().from(licenses).where(eq(licenses.customerEmail, email)); +} + +export async function revokeLicense(db: Db, stripeSubscriptionId: string): Promise { + const rows = await db + .update(licenses) + .set({ revokedAt: sql`now()`, updatedAt: sql`now()` }) + .where(eq(licenses.stripeSubscriptionId, stripeSubscriptionId)) + .returning(); + return rows[0] ?? null; +} + +export async function updateLicenseToken(db: Db, id: string, token: string): Promise { + const rows = await db + .update(licenses) + .set({ lastToken: token, issuedAt: sql`now()`, updatedAt: sql`now()` }) + .where(eq(licenses.id, id)) + .returning(); + if (!rows[0]) throw new Error(`updateLicenseToken: no license with id=${id}`); + return rows[0]; +} From 6d2484124e58aa027181ecec2d3c9dbaa9038861 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:42:23 -0700 Subject: [PATCH 35/51] feat(minting-service): scaffold Nx Node app Co-Authored-By: Claude Opus 4 --- apps/minting-service/eslint.config.mjs | 3 +++ apps/minting-service/package.json | 6 ++++++ apps/minting-service/project.json | 14 ++++++++++++++ apps/minting-service/src/.gitkeep | 0 apps/minting-service/tsconfig.app.json | 13 +++++++++++++ apps/minting-service/tsconfig.json | 8 ++++++++ apps/minting-service/tsconfig.spec.json | 8 ++++++++ apps/minting-service/vite.config.mts | 12 ++++++++++++ package-lock.json | 8 ++++++++ 9 files changed, 72 insertions(+) create mode 100644 apps/minting-service/eslint.config.mjs create mode 100644 apps/minting-service/package.json create mode 100644 apps/minting-service/project.json create mode 100644 apps/minting-service/src/.gitkeep create mode 100644 apps/minting-service/tsconfig.app.json create mode 100644 apps/minting-service/tsconfig.json create mode 100644 apps/minting-service/tsconfig.spec.json create mode 100644 apps/minting-service/vite.config.mts diff --git a/apps/minting-service/eslint.config.mjs b/apps/minting-service/eslint.config.mjs new file mode 100644 index 000000000..b7f62772e --- /dev/null +++ b/apps/minting-service/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [...baseConfig]; diff --git a/apps/minting-service/package.json b/apps/minting-service/package.json new file mode 100644 index 000000000..4beee9696 --- /dev/null +++ b/apps/minting-service/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/minting-service", + "version": "0.0.1", + "type": "module", + "private": true +} diff --git a/apps/minting-service/project.json b/apps/minting-service/project.json new file mode 100644 index 000000000..f423c85fd --- /dev/null +++ b/apps/minting-service/project.json @@ -0,0 +1,14 @@ +{ + "name": "minting-service", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/minting-service/src", + "projectType": "application", + "tags": ["scope:service", "type:app"], + "targets": { + "lint": { "executor": "@nx/eslint:lint" }, + "test": { + "executor": "@nx/vitest:test", + "options": { "configFile": "apps/minting-service/vite.config.mts" } + } + } +} diff --git a/apps/minting-service/src/.gitkeep b/apps/minting-service/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/minting-service/tsconfig.app.json b/apps/minting-service/tsconfig.app.json new file mode 100644 index 000000000..9a8978250 --- /dev/null +++ b/apps/minting-service/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "outDir": "../../dist/apps/minting-service", + "emitDeclarationOnly": false, + "declaration": false + }, + "include": ["src/**/*.ts", "api/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "api/**/*.spec.ts"] +} diff --git a/apps/minting-service/tsconfig.json b/apps/minting-service/tsconfig.json new file mode 100644 index 000000000..75f4b1604 --- /dev/null +++ b/apps/minting-service/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/apps/minting-service/tsconfig.spec.json b/apps/minting-service/tsconfig.spec.json new file mode 100644 index 000000000..003d988f8 --- /dev/null +++ b/apps/minting-service/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.spec.ts", "api/**/*.spec.ts", "src/**/*.ts", "api/**/*.ts"] +} diff --git a/apps/minting-service/vite.config.mts b/apps/minting-service/vite.config.mts new file mode 100644 index 000000000..7b289afd6 --- /dev/null +++ b/apps/minting-service/vite.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + environment: 'node', + globals: true, + include: ['src/**/*.spec.ts', 'api/**/*.spec.ts'], + passWithNoTests: true, + }, +}); diff --git a/package-lock.json b/package-lock.json index 8e93d0b4b..58852df7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,10 @@ "apps/demo-e2e": { "version": "0.0.1" }, + "apps/minting-service": { + "name": "@cacheplane/minting-service", + "version": "0.0.1" + }, "apps/website": { "version": "0.0.1", "dependencies": { @@ -6814,6 +6818,10 @@ "resolved": "packages/mcp", "link": true }, + "node_modules/@cacheplane/minting-service": { + "resolved": "apps/minting-service", + "link": true + }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", From cfd6cd3a7e86a7e6501e62abccf144d9ce4a6f4e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:45:56 -0700 Subject: [PATCH 36/51] feat(minting-service): add runtime deps and .env.example --- apps/minting-service/.env.example | 16 ++++++++++++ apps/minting-service/package.json | 8 +++++- package-lock.json | 43 ++++++++++++++++++++++++------- package.json | 4 ++- 4 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 apps/minting-service/.env.example diff --git a/apps/minting-service/.env.example b/apps/minting-service/.env.example new file mode 100644 index 000000000..4b2a9a7ef --- /dev/null +++ b/apps/minting-service/.env.example @@ -0,0 +1,16 @@ +# Stripe +STRIPE_SECRET_KEY=sk_test_replace_me +STRIPE_WEBHOOK_SECRET=whsec_replace_me + +# Postgres (Vercel Postgres connection string) +DATABASE_URL=postgres://user:pass@host:5432/db?sslmode=require + +# Resend +RESEND_API_KEY=re_replace_me +EMAIL_FROM=licenses@example.com + +# License signing (64 hex chars, 32 bytes Ed25519 private key) +LICENSE_SIGNING_PRIVATE_KEY_HEX=0000000000000000000000000000000000000000000000000000000000000000 + +# Optional: fallback TTL when a subscription has no current_period_end +LICENSE_DEFAULT_TTL_DAYS=365 diff --git a/apps/minting-service/package.json b/apps/minting-service/package.json index 4beee9696..35f92e80b 100644 --- a/apps/minting-service/package.json +++ b/apps/minting-service/package.json @@ -2,5 +2,11 @@ "name": "@cacheplane/minting-service", "version": "0.0.1", "type": "module", - "private": true + "private": true, + "dependencies": { + "drizzle-orm": "^0.45.2", + "postgres": "^3.4.9", + "resend": "^6.10.0", + "stripe": "^22.0.2" + } } diff --git a/package-lock.json b/package-lock.json index 58852df7e..8a2445a25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,8 @@ "rehype-pretty-code": "^0.14.3", "rehype-slug": "^6.0.0", "rxjs": "~7.8.0", - "shiki": "^4.0.2" + "shiki": "^4.0.2", + "stripe": "^22.0.2" }, "devDependencies": { "@analogjs/vite-plugin-angular": "^2.4.4", @@ -64,6 +65,7 @@ "@swc/helpers": "0.5.18", "@tailwindcss/postcss": "^4.2.2", "@testcontainers/postgresql": "^11.14.0", + "@types/node": "^25.6.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/utils": "^8.40.0", @@ -113,7 +115,13 @@ }, "apps/minting-service": { "name": "@cacheplane/minting-service", - "version": "0.0.1" + "version": "0.0.1", + "dependencies": { + "drizzle-orm": "^0.45.2", + "postgres": "^3.4.9", + "resend": "^6.10.0", + "stripe": "^22.0.2" + } }, "apps/website": { "version": "0.0.1", @@ -19845,13 +19853,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/node-forge": { @@ -39127,6 +39135,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.2.tgz", + "integrity": "sha512-2/BLrQ3oB1zlNfeL/LfHFjTGx6EQn0j+ztrrTJHuDjV5VVIpk92oSDaxyKLUr3pG3dnee2LZqhFUv2Bf0G1/3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strtok3": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", @@ -40356,9 +40381,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index b9d401d3e..0b7ac826d 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@swc/helpers": "0.5.18", "@tailwindcss/postcss": "^4.2.2", "@testcontainers/postgresql": "^11.14.0", + "@types/node": "^25.6.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/utils": "^8.40.0", @@ -93,7 +94,8 @@ "rehype-pretty-code": "^0.14.3", "rehype-slug": "^6.0.0", "rxjs": "~7.8.0", - "shiki": "^4.0.2" + "shiki": "^4.0.2", + "stripe": "^22.0.2" }, "nx": { "includedScripts": [], From 6091cb4b89bc1c3005c0e6e1528f3bda123b08aa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:48:24 -0700 Subject: [PATCH 37/51] feat(minting-service): add env var validation --- apps/minting-service/src/lib/env.spec.ts | 57 ++++++++++++++++++++++++ apps/minting-service/src/lib/env.ts | 41 +++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 apps/minting-service/src/lib/env.spec.ts create mode 100644 apps/minting-service/src/lib/env.ts diff --git a/apps/minting-service/src/lib/env.spec.ts b/apps/minting-service/src/lib/env.spec.ts new file mode 100644 index 000000000..aa35d80d9 --- /dev/null +++ b/apps/minting-service/src/lib/env.spec.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +const REQUIRED = { + STRIPE_SECRET_KEY: 'sk_test_xxx', + STRIPE_WEBHOOK_SECRET: 'whsec_xxx', + DATABASE_URL: 'postgres://u:p@h:5432/d', + RESEND_API_KEY: 're_xxx', + EMAIL_FROM: 'a@b.c', + LICENSE_SIGNING_PRIVATE_KEY_HEX: 'a'.repeat(64), +}; + +function setEnv(vars: Record) { + for (const [k, v] of Object.entries(vars)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +} + +describe('loadEnv', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('loads all required vars successfully', async () => { + setEnv(REQUIRED); + const { loadEnv } = await import('./env.js'); + const env = loadEnv(); + expect(env.STRIPE_SECRET_KEY).toBe('sk_test_xxx'); + expect(env.LICENSE_DEFAULT_TTL_DAYS).toBe(365); + }); + + it('throws with a list of all missing vars', async () => { + setEnv({ ...REQUIRED, STRIPE_SECRET_KEY: undefined, DATABASE_URL: undefined }); + const { loadEnv } = await import('./env.js'); + expect(() => loadEnv()).toThrow(/STRIPE_SECRET_KEY.*DATABASE_URL|DATABASE_URL.*STRIPE_SECRET_KEY/); + }); + + it('throws when private key hex is the wrong length', async () => { + setEnv({ ...REQUIRED, LICENSE_SIGNING_PRIVATE_KEY_HEX: 'abc' }); + const { loadEnv } = await import('./env.js'); + expect(() => loadEnv()).toThrow(/64 hex chars/); + }); + + it('throws when private key hex has non-hex characters', async () => { + setEnv({ ...REQUIRED, LICENSE_SIGNING_PRIVATE_KEY_HEX: 'z'.repeat(64) }); + const { loadEnv } = await import('./env.js'); + expect(() => loadEnv()).toThrow(/64 hex chars/); + }); + + it('accepts a custom LICENSE_DEFAULT_TTL_DAYS', async () => { + setEnv({ ...REQUIRED, LICENSE_DEFAULT_TTL_DAYS: '30' }); + const { loadEnv } = await import('./env.js'); + const env = loadEnv(); + expect(env.LICENSE_DEFAULT_TTL_DAYS).toBe(30); + }); +}); diff --git a/apps/minting-service/src/lib/env.ts b/apps/minting-service/src/lib/env.ts new file mode 100644 index 000000000..b15c41952 --- /dev/null +++ b/apps/minting-service/src/lib/env.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +const REQUIRED_VARS = [ + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'DATABASE_URL', + 'RESEND_API_KEY', + 'EMAIL_FROM', + 'LICENSE_SIGNING_PRIVATE_KEY_HEX', +] as const; + +export interface Env { + STRIPE_SECRET_KEY: string; + STRIPE_WEBHOOK_SECRET: string; + DATABASE_URL: string; + RESEND_API_KEY: string; + EMAIL_FROM: string; + LICENSE_SIGNING_PRIVATE_KEY_HEX: string; + LICENSE_DEFAULT_TTL_DAYS: number; +} + +export function loadEnv(): Env { + const missing = REQUIRED_VARS.filter((k) => !process.env[k]); + if (missing.length > 0) { + throw new Error(`Missing required env vars: ${missing.join(', ')}`); + } + + const keyHex = process.env['LICENSE_SIGNING_PRIVATE_KEY_HEX']!; + if (!/^[0-9a-f]{64}$/i.test(keyHex)) { + throw new Error('LICENSE_SIGNING_PRIVATE_KEY_HEX must be 64 hex chars (32 bytes)'); + } + + return { + STRIPE_SECRET_KEY: process.env['STRIPE_SECRET_KEY']!, + STRIPE_WEBHOOK_SECRET: process.env['STRIPE_WEBHOOK_SECRET']!, + DATABASE_URL: process.env['DATABASE_URL']!, + RESEND_API_KEY: process.env['RESEND_API_KEY']!, + EMAIL_FROM: process.env['EMAIL_FROM']!, + LICENSE_SIGNING_PRIVATE_KEY_HEX: keyHex, + LICENSE_DEFAULT_TTL_DAYS: Number(process.env['LICENSE_DEFAULT_TTL_DAYS'] ?? 365), + }; +} From ff392a0d071bde8caa9ca8db2fc1962d4e7bc7b9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:50:10 -0700 Subject: [PATCH 38/51] feat(minting-service): add tier extraction and seat computation --- apps/minting-service/src/lib/tier.spec.ts | 38 +++++++++++++++++++++++ apps/minting-service/src/lib/tier.ts | 34 ++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 apps/minting-service/src/lib/tier.spec.ts create mode 100644 apps/minting-service/src/lib/tier.ts diff --git a/apps/minting-service/src/lib/tier.spec.ts b/apps/minting-service/src/lib/tier.spec.ts new file mode 100644 index 000000000..b9a042cae --- /dev/null +++ b/apps/minting-service/src/lib/tier.spec.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { extractTier, computeSeats } from './tier.js'; + +describe('extractTier', () => { + it('returns developer-seat from price metadata', () => { + expect(extractTier({ cacheplane_tier: 'developer-seat' })).toBe('developer-seat'); + }); + + it('returns app-deployment from price metadata', () => { + expect(extractTier({ cacheplane_tier: 'app-deployment' })).toBe('app-deployment'); + }); + + it('throws when cacheplane_tier is missing', () => { + expect(() => extractTier({})).toThrow(/cacheplane_tier/); + }); + + it('throws when cacheplane_tier is an unknown value', () => { + expect(() => extractTier({ cacheplane_tier: 'bogus' })).toThrow(/bogus/); + }); + + it('throws when metadata is null', () => { + expect(() => extractTier(null)).toThrow(/metadata/); + }); +}); + +describe('computeSeats', () => { + it('returns the Stripe quantity for developer-seat', () => { + expect(computeSeats('developer-seat', 5)).toBe(5); + }); + + it('returns 1 for app-deployment regardless of quantity', () => { + expect(computeSeats('app-deployment', 10)).toBe(1); + }); + + it('defaults developer-seat to 1 when quantity is null', () => { + expect(computeSeats('developer-seat', null)).toBe(1); + }); +}); diff --git a/apps/minting-service/src/lib/tier.ts b/apps/minting-service/src/lib/tier.ts new file mode 100644 index 000000000..2c6453f03 --- /dev/null +++ b/apps/minting-service/src/lib/tier.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { LicenseTier } from '@cacheplane/licensing'; + +export type MintableTier = Extract; + +const VALID_TIERS: readonly MintableTier[] = ['developer-seat', 'app-deployment'] as const; + +/** + * Extract the Cacheplane tier from a Stripe price metadata bag. + * Throws if the field is missing or holds an unknown value. + */ +export function extractTier(metadata: Record | null | undefined): MintableTier { + if (!metadata) { + throw new Error('extractTier: price metadata is missing'); + } + const raw = metadata['cacheplane_tier']; + if (!raw) { + throw new Error('extractTier: metadata.cacheplane_tier is missing'); + } + if (!VALID_TIERS.includes(raw as MintableTier)) { + throw new Error(`extractTier: unknown cacheplane_tier value: ${raw}`); + } + return raw as MintableTier; +} + +/** + * Compute the `seats` claim from the Stripe line-item quantity. + * - developer-seat: tracks Stripe quantity (minimum 1). + * - app-deployment: always 1. + */ +export function computeSeats(tier: MintableTier, quantity: number | null | undefined): number { + if (tier === 'app-deployment') return 1; + return quantity && quantity > 0 ? quantity : 1; +} From 08711164b72394507729519955a6c02ac8fd6773 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:52:18 -0700 Subject: [PATCH 39/51] feat(minting-service): add mintToken wrapper over @cacheplane/licensing --- apps/minting-service/src/lib/sign.spec.ts | 53 +++++++++++++++++++++++ apps/minting-service/src/lib/sign.ts | 38 ++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 apps/minting-service/src/lib/sign.spec.ts create mode 100644 apps/minting-service/src/lib/sign.ts diff --git a/apps/minting-service/src/lib/sign.spec.ts b/apps/minting-service/src/lib/sign.spec.ts new file mode 100644 index 000000000..940732417 --- /dev/null +++ b/apps/minting-service/src/lib/sign.spec.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import * as ed from '@noble/ed25519'; +import { verifyLicense } from '@cacheplane/licensing'; +import { mintToken } from './sign.js'; + +async function makeKeypair() { + const sk = ed.utils.randomPrivateKey(); + const pk = await ed.getPublicKeyAsync(sk); + return { + skHex: Buffer.from(sk).toString('hex'), + pk, + }; +} + +describe('mintToken', () => { + it('returns a token verifiable with the matching public key', async () => { + const { skHex, pk } = await makeKeypair(); + + const token = await mintToken( + { + stripeCustomerId: 'cus_abc', + tier: 'developer-seat', + seats: 3, + expiresAt: new Date('2027-01-01T00:00:00Z'), + }, + skHex, + ); + + const result = await verifyLicense(token, pk); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.claims.sub).toBe('cus_abc'); + expect(result.claims.tier).toBe('developer-seat'); + expect(result.claims.seats).toBe(3); + expect(result.claims.exp).toBe(Math.floor(new Date('2027-01-01T00:00:00Z').getTime() / 1000)); + expect(result.claims.iat).toBeGreaterThan(0); + } + }); + + it('throws if the private key hex is malformed', async () => { + await expect( + mintToken( + { + stripeCustomerId: 'cus_x', + tier: 'app-deployment', + seats: 1, + expiresAt: new Date('2027-01-01T00:00:00Z'), + }, + 'not-hex', + ), + ).rejects.toThrow(); + }); +}); diff --git a/apps/minting-service/src/lib/sign.ts b/apps/minting-service/src/lib/sign.ts new file mode 100644 index 000000000..c5922a81e --- /dev/null +++ b/apps/minting-service/src/lib/sign.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { signLicense, type LicenseClaims } from '@cacheplane/licensing'; +import type { MintableTier } from './tier.js'; + +export interface MintInput { + stripeCustomerId: string; + tier: MintableTier; + seats: number; + expiresAt: Date; +} + +/** + * Mint a signed license token. `privateKeyHex` is a 64-char hex string + * encoding a 32-byte Ed25519 private key. + */ +export async function mintToken(input: MintInput, privateKeyHex: string): Promise { + const privateKey = hexToBytes(privateKeyHex); + const now = Math.floor(Date.now() / 1000); + const claims: LicenseClaims = { + sub: input.stripeCustomerId, + tier: input.tier, + iat: now, + exp: Math.floor(input.expiresAt.getTime() / 1000), + seats: input.seats, + }; + return signLicense(claims, privateKey); +} + +function hexToBytes(hex: string): Uint8Array { + if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) { + throw new Error('mintToken: privateKeyHex must be an even-length hex string'); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} From c1be7713ceb92c5c23f4f3fa04115cc66fa9d66e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:54:28 -0700 Subject: [PATCH 40/51] feat(minting-service): add license email renderer and Resend wrapper --- apps/minting-service/src/lib/email.spec.ts | 59 ++++++++++++ apps/minting-service/src/lib/email.ts | 103 +++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 apps/minting-service/src/lib/email.spec.ts create mode 100644 apps/minting-service/src/lib/email.ts diff --git a/apps/minting-service/src/lib/email.spec.ts b/apps/minting-service/src/lib/email.spec.ts new file mode 100644 index 000000000..0931c56f4 --- /dev/null +++ b/apps/minting-service/src/lib/email.spec.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { renderLicenseEmail } from './email.js'; + +describe('renderLicenseEmail', () => { + it('includes the token wrapped in BEGIN/END delimiters in the text body', () => { + const out = renderLicenseEmail({ + tier: 'developer-seat', + seats: 3, + token: 'PAYLOAD.SIG', + expiresAt: new Date('2027-04-20T00:00:00Z'), + }); + + expect(out.text).toContain('-----BEGIN CACHEPLANE LICENSE-----'); + expect(out.text).toContain('PAYLOAD.SIG'); + expect(out.text).toContain('-----END CACHEPLANE LICENSE-----'); + }); + + it('subject includes tier and seat count with plural s for seats > 1', () => { + const out = renderLicenseEmail({ + tier: 'developer-seat', + seats: 3, + token: 't.s', + expiresAt: new Date('2027-04-20T00:00:00Z'), + }); + expect(out.subject).toBe('Your Cacheplane license — developer-seat (3 seats)'); + }); + + it('subject uses singular seat for seats === 1', () => { + const out = renderLicenseEmail({ + tier: 'app-deployment', + seats: 1, + token: 't.s', + expiresAt: new Date('2027-04-20T00:00:00Z'), + }); + expect(out.subject).toBe('Your Cacheplane license — app-deployment (1 seat)'); + }); + + it('includes ISO 8601 UTC expiry in text body', () => { + const out = renderLicenseEmail({ + tier: 'developer-seat', + seats: 1, + token: 't.s', + expiresAt: new Date('2027-04-20T00:00:00Z'), + }); + expect(out.text).toContain('Expires: 2027-04-20T00:00:00.000Z'); + }); + + it('html body wraps the token in a monospace pre block', () => { + const out = renderLicenseEmail({ + tier: 'developer-seat', + seats: 1, + token: 'PAYLOAD.SIG', + expiresAt: new Date('2027-04-20T00:00:00Z'), + }); + expect(out.html).toContain(' + +Docs: https://cacheplane.dev/docs/licensing +Questions: reply to this email. + +-- The Cacheplane team +`; + + const html = `

Thanks for subscribing to Cacheplane.

+

Your license token is below. Set it as the CACHEPLANE_LICENSE environment variable in your application:

+
-----BEGIN CACHEPLANE LICENSE-----
+${escapeHtml(vars.token)}
+-----END CACHEPLANE LICENSE-----
+

Tier: ${escapeHtml(vars.tier)}
+Seats: ${vars.seats}
+Expires: ${escapeHtml(expiresIso)}

+

Installation:

+
export CACHEPLANE_LICENSE="<paste token above>"
+

Docs: cacheplane.dev/docs/licensing
+Questions: reply to this email.

+

-- The Cacheplane team

+`; + + return { subject, text, html }; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Send a license email via Resend. Throws on Resend errors so the caller + * (webhook handler) can fail the request and trigger Stripe retry. + */ +export async function sendLicenseEmail(args: { + resendApiKey: string; + from: string; + to: string; + vars: LicenseEmailVars; +}): Promise<{ resendId: string }> { + const resend = new Resend(args.resendApiKey); + const rendered = renderLicenseEmail(args.vars); + const result = await resend.emails.send({ + from: args.from, + to: args.to, + subject: rendered.subject, + text: rendered.text, + html: rendered.html, + }); + if (result.error) { + throw new Error(`Resend send failed: ${result.error.message}`); + } + if (!result.data?.id) { + throw new Error('Resend send returned no id'); + } + return { resendId: result.data.id }; +} From 351f0d05a79349360b62fc9f030d98ca384db1cd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:55:39 -0700 Subject: [PATCH 41/51] feat(minting-service): add Stripe SDK singleton --- apps/minting-service/src/lib/stripe.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/minting-service/src/lib/stripe.ts diff --git a/apps/minting-service/src/lib/stripe.ts b/apps/minting-service/src/lib/stripe.ts new file mode 100644 index 000000000..3721ee698 --- /dev/null +++ b/apps/minting-service/src/lib/stripe.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import Stripe from 'stripe'; + +let client: Stripe | null = null; + +/** + * Lazy-init a Stripe SDK client. Lives in its own module so tests can + * replace it via module mocks without the full env being loaded. + */ +export function getStripe(apiKey: string): Stripe { + if (!client) { + client = new Stripe(apiKey, { apiVersion: '2024-06-20' }); + } + return client; +} From 3eea711075ca30a36fd6f4102269ecc44de56e31 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:57:29 -0700 Subject: [PATCH 42/51] feat(minting-service): add handleEvent dispatcher with idempotency + compensating delete --- apps/minting-service/src/lib/handlers.spec.ts | 54 +++++++++++++ apps/minting-service/src/lib/handlers.ts | 78 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 apps/minting-service/src/lib/handlers.spec.ts create mode 100644 apps/minting-service/src/lib/handlers.ts diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts new file mode 100644 index 000000000..1c53af37e --- /dev/null +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type Stripe from 'stripe'; +import { handleEvent, type HandlerDeps } from './handlers.js'; + +function makeDeps(overrides: Partial = {}): HandlerDeps { + return { + db: {} as any, + stripe: {} as any, + markEventProcessed: vi.fn().mockResolvedValue(true), + deleteProcessedEvent: vi.fn().mockResolvedValue(undefined), + upsertLicense: vi.fn(), + getLicense: vi.fn(), + revokeLicense: vi.fn(), + mintToken: vi.fn(), + sendLicenseEmail: vi.fn(), + privateKeyHex: 'a'.repeat(64), + resendApiKey: 're_test', + emailFrom: 'a@b.c', + defaultTtlDays: 365, + ...overrides, + }; +} + +function evt(type: string, obj: unknown = {}): Stripe.Event { + return { id: `evt_${type}`, type, data: { object: obj } } as Stripe.Event; +} + +describe('handleEvent', () => { + it('returns early if markEventProcessed returns false (duplicate)', async () => { + const deps = makeDeps({ + markEventProcessed: vi.fn().mockResolvedValue(false), + }); + await handleEvent(evt('customer.subscription.deleted', { id: 'sub_x' }), deps); + expect(deps.revokeLicense).not.toHaveBeenCalled(); + }); + + it('no-ops on unknown event types', async () => { + const deps = makeDeps(); + await handleEvent(evt('invoice.payment_succeeded'), deps); + expect(deps.revokeLicense).not.toHaveBeenCalled(); + expect(deps.upsertLicense).not.toHaveBeenCalled(); + }); + + it('compensating-deletes the processed-event marker when handler throws', async () => { + const boom = new Error('boom'); + const deps = makeDeps({ + revokeLicense: vi.fn().mockRejectedValue(boom), + }); + await expect( + handleEvent(evt('customer.subscription.deleted', { id: 'sub_boom' }), deps), + ).rejects.toBe(boom); + expect(deps.deleteProcessedEvent).toHaveBeenCalledWith(deps.db, 'evt_customer.subscription.deleted'); + }); +}); diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts new file mode 100644 index 000000000..ae7566f48 --- /dev/null +++ b/apps/minting-service/src/lib/handlers.ts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type Stripe from 'stripe'; +import type { + Db, + License, + UpsertLicenseInput, +} from '@cacheplane/db'; +import type { MintInput } from './sign.js'; +import type { LicenseEmailVars } from './email.js'; + +/** + * All external collaborators are injected so handlers are unit-testable. + */ +export interface HandlerDeps { + db: Db; + stripe: Stripe; + markEventProcessed: (db: Db, id: string, type: string) => Promise; + deleteProcessedEvent: (db: Db, id: string) => Promise; + upsertLicense: (db: Db, input: UpsertLicenseInput) => Promise; + getLicense: (db: Db, subId: string) => Promise; + revokeLicense: (db: Db, subId: string) => Promise; + mintToken: (input: MintInput, privateKeyHex: string) => Promise; + sendLicenseEmail: (args: { + resendApiKey: string; + from: string; + to: string; + vars: LicenseEmailVars; + }) => Promise<{ resendId: string }>; + privateKeyHex: string; + resendApiKey: string; + emailFrom: string; + defaultTtlDays: number; +} + +export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promise { + const firstTime = await deps.markEventProcessed(deps.db, event.id, event.type); + if (!firstTime) return; + + try { + switch (event.type) { + case 'checkout.session.completed': + await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, deps); + break; + case 'customer.subscription.updated': + await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, deps); + break; + case 'customer.subscription.deleted': + await handleSubscriptionDeleted(event.data.object as Stripe.Subscription, deps); + break; + default: + return; + } + } catch (err) { + await deps.deleteProcessedEvent(deps.db, event.id); + throw err; + } +} + +export async function handleCheckoutCompleted( + _session: Stripe.Checkout.Session, + _deps: HandlerDeps, +): Promise { + throw new Error('handleCheckoutCompleted: not yet implemented'); +} + +export async function handleSubscriptionUpdated( + _sub: Stripe.Subscription, + _deps: HandlerDeps, +): Promise { + throw new Error('handleSubscriptionUpdated: not yet implemented'); +} + +export async function handleSubscriptionDeleted( + sub: Stripe.Subscription, + deps: HandlerDeps, +): Promise { + await deps.revokeLicense(deps.db, sub.id); +} From 84595278aa66586225cb0202537c9010672770b8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 14:59:50 -0700 Subject: [PATCH 43/51] feat(minting-service): implement handleCheckoutCompleted --- apps/minting-service/src/lib/handlers.spec.ts | 73 +++++++++++++++++++ apps/minting-service/src/lib/handlers.ts | 58 ++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts index 1c53af37e..b28ed52a1 100644 --- a/apps/minting-service/src/lib/handlers.spec.ts +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -52,3 +52,76 @@ describe('handleEvent', () => { expect(deps.deleteProcessedEvent).toHaveBeenCalledWith(deps.db, 'evt_customer.subscription.deleted'); }); }); + +describe('handleCheckoutCompleted', () => { + function baseSession(overrides: any = {}): Stripe.Checkout.Session { + return { + id: 'cs_test', + customer: 'cus_x', + subscription: 'sub_x', + customer_details: { email: 'a@b.c' }, + ...overrides, + } as Stripe.Checkout.Session; + } + + function baseDeps(): HandlerDeps { + const lineItem = { + data: [ + { + quantity: 2, + price: { metadata: { cacheplane_tier: 'developer-seat' } }, + }, + ], + }; + const sub = { current_period_end: 1_800_000_000, id: 'sub_x' }; + const expandedSession = baseSession({ line_items: lineItem }); + + return makeDeps({ + stripe: { + checkout: { + sessions: { + retrieve: vi.fn().mockResolvedValue(expandedSession), + }, + }, + subscriptions: { + retrieve: vi.fn().mockResolvedValue(sub), + }, + } as any, + mintToken: vi.fn().mockResolvedValue('TOKEN.SIG'), + upsertLicense: vi.fn().mockImplementation((_db, input) => + Promise.resolve({ ...input, id: 'lic_1', createdAt: new Date(), updatedAt: new Date(), issuedAt: new Date(), revokedAt: null }), + ), + sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_1' }), + }); + } + + it('upserts a license row and sends an email', async () => { + const deps = baseDeps(); + await handleEvent( + { id: 'evt_co', type: 'checkout.session.completed', data: { object: baseSession() } } as Stripe.Event, + deps, + ); + expect(deps.upsertLicense).toHaveBeenCalledTimes(1); + const upsertArg = (deps.upsertLicense as unknown as { mock: { calls: any[][] } }).mock.calls[0][1]; + expect(upsertArg.stripeSubscriptionId).toBe('sub_x'); + expect(upsertArg.tier).toBe('developer-seat'); + expect(upsertArg.seats).toBe(2); + expect(upsertArg.customerEmail).toBe('a@b.c'); + expect(upsertArg.lastToken).toBe('TOKEN.SIG'); + expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); + + it('throws when cacheplane_tier is missing from price metadata', async () => { + const deps = baseDeps(); + (deps.stripe.checkout.sessions.retrieve as unknown as ReturnType).mockResolvedValueOnce( + baseSession({ line_items: { data: [{ quantity: 1, price: { metadata: {} } }] } }), + ); + await expect( + handleEvent( + { id: 'evt_co2', type: 'checkout.session.completed', data: { object: baseSession() } } as Stripe.Event, + deps, + ), + ).rejects.toThrow(/cacheplane_tier/); + expect(deps.deleteProcessedEvent).toHaveBeenCalledWith(deps.db, 'evt_co2'); + }); +}); diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts index ae7566f48..31d40e716 100644 --- a/apps/minting-service/src/lib/handlers.ts +++ b/apps/minting-service/src/lib/handlers.ts @@ -7,6 +7,7 @@ import type { } from '@cacheplane/db'; import type { MintInput } from './sign.js'; import type { LicenseEmailVars } from './email.js'; +import { extractTier, computeSeats } from './tier.js'; /** * All external collaborators are injected so handlers are unit-testable. @@ -57,10 +58,61 @@ export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promi } export async function handleCheckoutCompleted( - _session: Stripe.Checkout.Session, - _deps: HandlerDeps, + session: Stripe.Checkout.Session, + deps: HandlerDeps, ): Promise { - throw new Error('handleCheckoutCompleted: not yet implemented'); + const expanded = await deps.stripe.checkout.sessions.retrieve(session.id, { + expand: ['line_items.data.price'], + }); + const lineItem = expanded.line_items?.data?.[0]; + if (!lineItem) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no line items`); + } + const priceMetadata = (lineItem.price?.metadata ?? {}) as Record; + const tier = extractTier(priceMetadata); + const seats = computeSeats(tier, lineItem.quantity); + + const subId = typeof expanded.subscription === 'string' + ? expanded.subscription + : expanded.subscription?.id; + if (!subId) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no subscription`); + } + const sub = await deps.stripe.subscriptions.retrieve(subId); + const expiresAt = (sub as any).current_period_end + ? new Date((sub as any).current_period_end * 1000) + : new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000); + + const customerId = typeof expanded.customer === 'string' ? expanded.customer : expanded.customer?.id; + if (!customerId) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer`); + } + const email = expanded.customer_details?.email; + if (!email) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer email`); + } + + const token = await deps.mintToken( + { stripeCustomerId: customerId, tier, seats, expiresAt }, + deps.privateKeyHex, + ); + + await deps.upsertLicense(deps.db, { + stripeCustomerId: customerId, + stripeSubscriptionId: subId, + customerEmail: email, + tier, + seats, + expiresAt, + lastToken: token, + }); + + await deps.sendLicenseEmail({ + resendApiKey: deps.resendApiKey, + from: deps.emailFrom, + to: email, + vars: { tier, seats, token, expiresAt }, + }); } export async function handleSubscriptionUpdated( From 43f86e580722ddd70aaec63a6d319b1ef8f81eec Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 15:02:40 -0700 Subject: [PATCH 44/51] feat(minting-service): implement handleSubscriptionUpdated with material-change check --- apps/minting-service/src/lib/handlers.spec.ts | 114 ++++++++++++++++++ apps/minting-service/src/lib/handlers.ts | 77 +++++++++++- 2 files changed, 188 insertions(+), 3 deletions(-) diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts index b28ed52a1..9e483acd5 100644 --- a/apps/minting-service/src/lib/handlers.spec.ts +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import type Stripe from 'stripe'; +import type { License } from '@cacheplane/db'; import { handleEvent, type HandlerDeps } from './handlers.js'; function makeDeps(overrides: Partial = {}): HandlerDeps { @@ -125,3 +126,116 @@ describe('handleCheckoutCompleted', () => { expect(deps.deleteProcessedEvent).toHaveBeenCalledWith(deps.db, 'evt_co2'); }); }); + +describe('handleSubscriptionUpdated', () => { + function sub(overrides: any = {}): Stripe.Subscription { + return { + id: 'sub_u', + customer: 'cus_u', + current_period_end: 1_800_000_000, + items: { + data: [ + { + quantity: 3, + price: { metadata: { cacheplane_tier: 'developer-seat' } }, + }, + ], + }, + ...overrides, + } as Stripe.Subscription; + } + + function existingLicense(overrides: Partial = {}): License { + return { + id: 'lic_u', + stripeCustomerId: 'cus_u', + stripeSubscriptionId: 'sub_u', + customerEmail: 'u@example.com', + tier: 'developer-seat', + seats: 3, + expiresAt: new Date(1_800_000_000 * 1000), + revokedAt: null, + lastToken: 'OLD.TOKEN', + issuedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as License; + } + + function deps(license: License | null): HandlerDeps { + return makeDeps({ + getLicense: vi.fn().mockResolvedValue(license), + upsertLicense: vi.fn().mockImplementation((_db, input) => + Promise.resolve({ ...(license ?? {}), ...input, id: 'lic_u', createdAt: new Date(), updatedAt: new Date(), issuedAt: new Date(), revokedAt: null }), + ), + mintToken: vi.fn().mockResolvedValue('NEW.TOKEN'), + sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_u' }), + stripe: { + checkout: { sessions: { retrieve: vi.fn() } }, + subscriptions: { retrieve: vi.fn() }, + } as any, + }); + } + + it('upserts without minting or emailing when claims are unchanged', async () => { + const d = deps(existingLicense()); + await handleEvent( + { id: 'evt_u_noop', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).not.toHaveBeenCalled(); + expect(d.sendLicenseEmail).not.toHaveBeenCalled(); + expect(d.upsertLicense).toHaveBeenCalledTimes(1); + const arg = (d.upsertLicense as unknown as { mock: { calls: any[][] } }).mock.calls[0][1]; + expect(arg.lastToken).toBe('OLD.TOKEN'); + }); + + it('mints and emails when seats change', async () => { + const d = deps(existingLicense({ seats: 2 })); + await handleEvent( + { id: 'evt_u_seats', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).toHaveBeenCalledTimes(1); + expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); + const arg = (d.upsertLicense as unknown as { mock: { calls: any[][] } }).mock.calls[0][1]; + expect(arg.lastToken).toBe('NEW.TOKEN'); + expect(arg.seats).toBe(3); + }); + + it('mints and emails when tier changes', async () => { + const d = deps(existingLicense({ tier: 'app-deployment', seats: 1 })); + await handleEvent( + { id: 'evt_u_tier', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).toHaveBeenCalledTimes(1); + expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); + + it('mints and emails when expires_at changes', async () => { + const d = deps(existingLicense({ expiresAt: new Date(1_700_000_000 * 1000) })); + await handleEvent( + { id: 'evt_u_exp', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).toHaveBeenCalledTimes(1); + expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); + + it('mints and emails when no existing license is found (first time)', async () => { + const d = deps(null); + (d.stripe as any).customers = { + retrieve: vi.fn().mockResolvedValue({ email: 'new@example.com' }), + }; + await handleEvent( + { id: 'evt_u_new', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, + d, + ); + expect(d.mintToken).toHaveBeenCalledTimes(1); + expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); + const sendArg = (d.sendLicenseEmail as unknown as { mock: { calls: any[][] } }).mock.calls[0][0]; + expect(sendArg.to).toBe('new@example.com'); + }); +}); diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts index 31d40e716..dfbb9eba4 100644 --- a/apps/minting-service/src/lib/handlers.ts +++ b/apps/minting-service/src/lib/handlers.ts @@ -116,10 +116,81 @@ export async function handleCheckoutCompleted( } export async function handleSubscriptionUpdated( - _sub: Stripe.Subscription, - _deps: HandlerDeps, + sub: Stripe.Subscription, + deps: HandlerDeps, ): Promise { - throw new Error('handleSubscriptionUpdated: not yet implemented'); + const lineItem = sub.items?.data?.[0]; + if (!lineItem) { + throw new Error(`handleSubscriptionUpdated: subscription ${sub.id} has no items`); + } + const priceMetadata = (lineItem.price?.metadata ?? {}) as Record; + const tier = extractTier(priceMetadata); + const seats = computeSeats(tier, lineItem.quantity); + const currentPeriodEnd = (sub as any).current_period_end as number | undefined; + const expiresAt = currentPeriodEnd + ? new Date(currentPeriodEnd * 1000) + : new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000); + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; + if (!customerId) { + throw new Error(`handleSubscriptionUpdated: subscription ${sub.id} has no customer`); + } + + const existing = await deps.getLicense(deps.db, sub.id); + + const claimsUnchanged = + existing !== null && + existing.tier === tier && + existing.seats === seats && + existing.expiresAt.getTime() === expiresAt.getTime(); + + // Email source: prefer existing license (captured at checkout), else pull + // from Stripe customer. + let email = existing?.customerEmail; + if (!email) { + const customer = await deps.stripe.customers.retrieve(customerId); + if ('deleted' in customer && customer.deleted) { + throw new Error(`handleSubscriptionUpdated: customer ${customerId} is deleted`); + } + email = (customer as Stripe.Customer).email ?? undefined; + if (!email) { + throw new Error(`handleSubscriptionUpdated: no email for customer ${customerId}`); + } + } + + if (claimsUnchanged && existing) { + await deps.upsertLicense(deps.db, { + stripeCustomerId: existing.stripeCustomerId, + stripeSubscriptionId: existing.stripeSubscriptionId, + customerEmail: existing.customerEmail, + tier: existing.tier, + seats: existing.seats, + expiresAt: existing.expiresAt, + lastToken: existing.lastToken, + }); + return; + } + + const token = await deps.mintToken( + { stripeCustomerId: customerId, tier, seats, expiresAt }, + deps.privateKeyHex, + ); + + await deps.upsertLicense(deps.db, { + stripeCustomerId: customerId, + stripeSubscriptionId: sub.id, + customerEmail: email, + tier, + seats, + expiresAt, + lastToken: token, + }); + + await deps.sendLicenseEmail({ + resendApiKey: deps.resendApiKey, + from: deps.emailFrom, + to: email, + vars: { tier, seats, token, expiresAt }, + }); } export async function handleSubscriptionDeleted( From 740fcc2f641ff8f773b631fc20fffd4244496866 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 15:03:29 -0700 Subject: [PATCH 45/51] test(minting-service): lock in handleSubscriptionDeleted contract --- apps/minting-service/src/lib/handlers.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts index 9e483acd5..4f9234a28 100644 --- a/apps/minting-service/src/lib/handlers.spec.ts +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -239,3 +239,30 @@ describe('handleSubscriptionUpdated', () => { expect(sendArg.to).toBe('new@example.com'); }); }); + +describe('handleSubscriptionDeleted', () => { + it('calls revokeLicense and does not email or mint', async () => { + const d = makeDeps({ + revokeLicense: vi.fn().mockResolvedValue({ id: 'lic_d' }), + }); + await handleEvent( + { id: 'evt_del', type: 'customer.subscription.deleted', data: { object: { id: 'sub_d' } } } as Stripe.Event, + d, + ); + expect(d.revokeLicense).toHaveBeenCalledWith(d.db, 'sub_d'); + expect(d.mintToken).not.toHaveBeenCalled(); + expect(d.sendLicenseEmail).not.toHaveBeenCalled(); + }); + + it('is idempotent — no throw if license is already absent', async () => { + const d = makeDeps({ + revokeLicense: vi.fn().mockResolvedValue(null), + }); + await expect( + handleEvent( + { id: 'evt_del2', type: 'customer.subscription.deleted', data: { object: { id: 'sub_nope' } } } as Stripe.Event, + d, + ), + ).resolves.toBeUndefined(); + }); +}); From d65b2e5b3f8497fd1bca980d52bc0784f9361930 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 15:06:08 -0700 Subject: [PATCH 46/51] feat(minting-service): add /api/health probe Also fixes two type-level issues exposed when the api/ directory brought tsc --noEmit into scope: - tsconfig.app.json: disable composite/declaration locally since libs are consumed via tsconfig path aliases (not project references), so composite mode can't see imported lib sources. - stripe.ts: cast apiVersion '2024-06-20' through any because the SDK's LatestApiVersion literal only admits '2026-03-25.dahlia'; we pin to 2024-06-20 at runtime for subscription shape stability. --- apps/minting-service/api/health.ts | 6 + apps/minting-service/src/lib/stripe.ts | 6 +- apps/minting-service/tsconfig.app.json | 6 +- package-lock.json | 1194 ++++++++++++++++++++++++ package.json | 1 + 5 files changed, 1210 insertions(+), 3 deletions(-) create mode 100644 apps/minting-service/api/health.ts diff --git a/apps/minting-service/api/health.ts b/apps/minting-service/api/health.ts new file mode 100644 index 000000000..fbd97ee0f --- /dev/null +++ b/apps/minting-service/api/health.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { VercelRequest, VercelResponse } from '@vercel/node'; + +export default function handler(_req: VercelRequest, res: VercelResponse): void { + res.status(200).json({ ok: true }); +} diff --git a/apps/minting-service/src/lib/stripe.ts b/apps/minting-service/src/lib/stripe.ts index 3721ee698..107095b75 100644 --- a/apps/minting-service/src/lib/stripe.ts +++ b/apps/minting-service/src/lib/stripe.ts @@ -9,7 +9,11 @@ let client: Stripe | null = null; */ export function getStripe(apiKey: string): Stripe { if (!client) { - client = new Stripe(apiKey, { apiVersion: '2024-06-20' }); + // apiVersion pinned to 2024-06-20 for subscription shape stability + // (current_period_end remained at top-level in this version). Cast needed + // because Stripe SDK types only admit the latest LatestApiVersion literal. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client = new Stripe(apiKey, { apiVersion: '2024-06-20' as any }); } return client; } diff --git a/apps/minting-service/tsconfig.app.json b/apps/minting-service/tsconfig.app.json index 9a8978250..55138b74e 100644 --- a/apps/minting-service/tsconfig.app.json +++ b/apps/minting-service/tsconfig.app.json @@ -5,8 +5,10 @@ "moduleResolution": "NodeNext", "target": "ES2022", "outDir": "../../dist/apps/minting-service", - "emitDeclarationOnly": false, - "declaration": false + "composite": false, + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false }, "include": ["src/**/*.ts", "api/**/*.ts"], "exclude": ["src/**/*.spec.ts", "api/**/*.spec.ts"] diff --git a/package-lock.json b/package-lock.json index 8a2445a25..9bea77c80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/utils": "^8.40.0", + "@vercel/node": "^5.7.12", "@vitest/coverage-v8": "^4.1.0", "angular-eslint": "^21.0.1", "autoprefixer": "^10.4.27", @@ -6822,6 +6823,13 @@ "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@bytecodealliance/preview2-shim": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz", + "integrity": "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)" + }, "node_modules/@cacheplane/angular-mcp": { "resolved": "packages/mcp", "link": true @@ -6998,6 +7006,59 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@edge-runtime/format": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@edge-runtime/format/-/format-2.2.1.tgz", + "integrity": "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/node-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/node-utils/-/node-utils-2.3.0.tgz", + "integrity": "sha512-uUtx8BFoO1hNxtHjp3eqVPC/mWImGb2exOfGjMLUoipuWgjej+f4o/VP4bUI8U40gu7Teogd5VTeZUkGvJSPOQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/ponyfill": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@edge-runtime/ponyfill/-/ponyfill-2.4.2.tgz", + "integrity": "sha512-oN17GjFr69chu6sDLvXxdhg0Qe8EZviGSuqzR9qOiKh4MhFYGdBBcqRNzdmYeAdeRzOW2mM9yil4RftUQ7sUOA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/primitives": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-4.1.0.tgz", + "integrity": "sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-3.2.0.tgz", + "integrity": "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@edge-runtime/primitives": "4.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -8147,6 +8208,16 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@gar/promise-retry": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", @@ -10248,6 +10319,54 @@ "dev": true, "license": "LGPL-3.0" }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", + "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.4.0" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -16913,6 +17032,17 @@ } } }, + "node_modules/@renovatebot/pep440": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz", + "integrity": "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.9.0 || ^22.11.0 || ^24", + "pnpm": "^10.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.58", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.58.tgz", @@ -20372,6 +20502,853 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vercel/build-utils": { + "version": "13.19.1", + "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.19.1.tgz", + "integrity": "sha512-TRb8x9MuRrBYx7SjwW4hGnEMTnEPDk/jCQRjziS7AkPj0ahayqYlSjoZG3AWKc5NLOwulLekGbhQXUMRAdaA/A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/python-analysis": "0.11.0", + "cjs-module-lexer": "1.2.3", + "es-module-lexer": "1.5.0" + } + }, + "node_modules/@vercel/build-utils/node_modules/es-module-lexer": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", + "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/error-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.0.3.tgz", + "integrity": "sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@vercel/nft": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz", + "integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^2.0.0", + "@rollup/pluginutils": "^5.1.3", + "acorn": "^8.6.0", + "acorn-import-attributes": "^1.9.5", + "async-sema": "^3.1.1", + "bindings": "^1.4.0", + "estree-walker": "2.0.2", + "glob": "^13.0.0", + "graceful-fs": "^4.2.9", + "node-gyp-build": "^4.2.2", + "picomatch": "^4.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "nft": "out/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@vercel/nft/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@vercel/node": { + "version": "5.7.12", + "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.7.12.tgz", + "integrity": "sha512-ClU5ttdwMUr+IC43nNdRhHQR9XnIaF1SNUSJ0IrTQvqXt9ItLvS1zEDRF83VPLVFs4Rd6WtSB2kcGGsRzHjcEw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@edge-runtime/node-utils": "2.3.0", + "@edge-runtime/primitives": "4.1.0", + "@edge-runtime/vm": "3.2.0", + "@types/node": "20.11.0", + "@vercel/build-utils": "13.19.1", + "@vercel/error-utils": "2.0.3", + "@vercel/nft": "1.5.0", + "@vercel/static-config": "3.2.0", + "async-listen": "3.0.0", + "cjs-module-lexer": "1.2.3", + "edge-runtime": "2.5.9", + "es-module-lexer": "1.4.1", + "esbuild": "0.27.0", + "etag": "1.8.1", + "mime-types": "2.1.35", + "node-fetch": "2.6.9", + "path-to-regexp": "6.1.0", + "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", + "ts-morph": "12.0.0", + "tsx": "4.21.0", + "typescript": "npm:typescript@5.9.3", + "undici": "5.28.4" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/node/node_modules/@ts-morph/common": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.11.1.tgz", + "integrity": "sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.7", + "minimatch": "^3.0.4", + "mkdirp": "^1.0.4", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vercel/node/node_modules/@types/node": { + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", + "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@vercel/node/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@vercel/node/node_modules/code-block-writer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-10.1.1.tgz", + "integrity": "sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/node/node_modules/es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/node/node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/@vercel/node/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@vercel/node/node_modules/node-fetch": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@vercel/node/node_modules/path-to-regexp": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz", + "integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/node/node_modules/ts-morph": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-12.0.0.tgz", + "integrity": "sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.11.0", + "code-block-writer": "^10.1.1" + } + }, + "node_modules/@vercel/node/node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/@vercel/node/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/python-analysis": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.11.0.tgz", + "integrity": "sha512-gsoj+nscmNm0xDh+tRhECRhit2VlAVaD7jc9h93sN6rDEBDxPo7eLEgIJFzVDaAItxERZ9Od2IK/04fB9vFy+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bytecodealliance/preview2-shim": "0.17.6", + "@renovatebot/pep440": "4.2.1", + "fs-extra": "11.1.1", + "js-yaml": "4.1.1", + "minimatch": "10.1.1", + "smol-toml": "1.5.2", + "zod": "3.22.4" + } + }, + "node_modules/@vercel/python-analysis/node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@vercel/python-analysis/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@vercel/python-analysis/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vercel/static-config": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.2.0.tgz", + "integrity": "sha512-UpOEIgWxWx0M+mDe1IMdHS6JuWM/L5nNIJ4ixX8v9JgBAejymo88OkgnmfLCNMem0Wd+b5vcQPWLdZybCndlsA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "8.6.3", + "json-schema-to-ts": "1.6.4", + "ts-morph": "12.0.0" + } + }, + "node_modules/@vercel/static-config/node_modules/@ts-morph/common": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.11.1.tgz", + "integrity": "sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.7", + "minimatch": "^3.0.4", + "mkdirp": "^1.0.4", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vercel/static-config/node_modules/ajv": { + "version": "8.6.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz", + "integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@vercel/static-config/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@vercel/static-config/node_modules/code-block-writer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-10.1.1.tgz", + "integrity": "sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/static-config/node_modules/json-schema-to-ts": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-1.6.4.tgz", + "integrity": "sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.6", + "ts-toolbelt": "^6.15.5" + } + }, + "node_modules/@vercel/static-config/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@vercel/static-config/node_modules/ts-morph": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-12.0.0.tgz", + "integrity": "sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.11.0", + "code-block-writer": "^10.1.1" + } + }, "node_modules/@verdaccio/auth": { "version": "8.0.0-next-8.33", "resolved": "https://registry.npmjs.org/@verdaccio/auth/-/auth-8.0.0-next-8.33.tgz", @@ -21536,6 +22513,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/acorn-import-phases": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", @@ -22168,6 +23155,16 @@ "dev": true, "license": "MIT" }, + "node_modules/async-listen": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.0.tgz", + "integrity": "sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/async-lock": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", @@ -22175,6 +23172,13 @@ "dev": true, "license": "MIT" }, + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -22718,6 +23722,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -23328,6 +24342,13 @@ "node": ">=8" } }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -23801,6 +24822,16 @@ "node": ">=0.8" } }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/console-table-printer": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", @@ -23832,6 +24863,16 @@ "node": ">= 0.6" } }, + "node_modules/convert-hrtime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-3.0.0.tgz", + "integrity": "sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -25722,6 +26763,60 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/edge-runtime": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/edge-runtime/-/edge-runtime-2.5.9.tgz", + "integrity": "sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@edge-runtime/format": "2.2.1", + "@edge-runtime/ponyfill": "2.4.2", + "@edge-runtime/vm": "3.2.0", + "async-listen": "3.0.1", + "mri": "1.2.0", + "picocolors": "1.0.0", + "pretty-ms": "7.0.1", + "signal-exit": "4.0.2", + "time-span": "4.0.0" + }, + "bin": { + "edge-runtime": "dist/cli/index.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/edge-runtime/node_modules/async-listen": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.1.tgz", + "integrity": "sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/edge-runtime/node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/edge-runtime/node_modules/signal-exit": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", + "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -26988,6 +28083,13 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -33051,6 +34153,16 @@ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", "license": "MIT" }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -33686,6 +34798,18 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", @@ -34494,6 +35618,16 @@ "dev": true, "license": "MIT" }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse-node-version": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", @@ -34683,6 +35817,14 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp-updated": { + "name": "path-to-regexp", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -35988,6 +37130,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -38533,6 +39691,19 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -39721,6 +40892,22 @@ "dev": true, "license": "MIT" }, + "node_modules/time-span": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-4.0.0.tgz", + "integrity": "sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-hrtime": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -40081,6 +41268,13 @@ "code-block-writer": "^12.0.0" } }, + "node_modules/ts-toolbelt": { + "version": "6.15.5", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz", + "integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", diff --git a/package.json b/package.json index 0b7ac826d..1b91126f6 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/utils": "^8.40.0", + "@vercel/node": "^5.7.12", "@vitest/coverage-v8": "^4.1.0", "angular-eslint": "^21.0.1", "autoprefixer": "^10.4.27", From e1ce40822c33b952bc32dd1a4a18c28095e9a63e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 15:06:35 -0700 Subject: [PATCH 47/51] feat(minting-service): add /api/stripe-webhook endpoint --- apps/minting-service/api/stripe-webhook.ts | 79 ++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 apps/minting-service/api/stripe-webhook.ts diff --git a/apps/minting-service/api/stripe-webhook.ts b/apps/minting-service/api/stripe-webhook.ts new file mode 100644 index 000000000..0ffeea866 --- /dev/null +++ b/apps/minting-service/api/stripe-webhook.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import type { IncomingMessage } from 'node:http'; +import { + createDb, + markEventProcessed, + deleteProcessedEvent, + upsertLicense, + getLicense, + revokeLicense, +} from '@cacheplane/db'; +import { loadEnv } from '../src/lib/env.js'; +import { getStripe } from '../src/lib/stripe.js'; +import { mintToken } from '../src/lib/sign.js'; +import { sendLicenseEmail } from '../src/lib/email.js'; +import { handleEvent, type HandlerDeps } from '../src/lib/handlers.js'; + +export const config = { api: { bodyParser: false } }; + +async function readRawBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +export default async function handler(req: VercelRequest, res: VercelResponse): Promise { + if (req.method !== 'POST') { + res.status(405).end(); + return; + } + + const env = loadEnv(); + const stripe = getStripe(env.STRIPE_SECRET_KEY); + + const rawBody = await readRawBody(req); + const sig = req.headers['stripe-signature']; + if (typeof sig !== 'string') { + res.status(400).send('missing signature'); + return; + } + + let event; + try { + event = stripe.webhooks.constructEvent(rawBody, sig, env.STRIPE_WEBHOOK_SECRET); + } catch (err) { + console.error('stripe signature verification failed', err); + res.status(400).send('invalid signature'); + return; + } + + const db = createDb(env.DATABASE_URL); + const deps: HandlerDeps = { + db, + stripe, + markEventProcessed, + deleteProcessedEvent, + upsertLicense, + getLicense, + revokeLicense, + mintToken, + sendLicenseEmail, + privateKeyHex: env.LICENSE_SIGNING_PRIVATE_KEY_HEX, + resendApiKey: env.RESEND_API_KEY, + emailFrom: env.EMAIL_FROM, + defaultTtlDays: env.LICENSE_DEFAULT_TTL_DAYS, + }; + + try { + await handleEvent(event, deps); + res.status(200).json({ received: true }); + } catch (err) { + console.error('webhook handler error', { eventId: event.id, type: event.type, err }); + res.status(500).send('internal error'); + } finally { + await db.close(); + } +} From c8e80ca3f9babd70c8766641bc3fa1a816704f71 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 15:07:02 -0700 Subject: [PATCH 48/51] feat(minting-service): add Vercel deployment config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses npm ci (not pnpm — this repo is npm-based) and tsc --noEmit as the build command since api/*.ts files are compiled by Vercel's own runtime. The @cacheplane/db and @cacheplane/licensing imports resolve via tsconfig path aliases at build-time; runtime resolution will need verification during first deploy. --- apps/minting-service/vercel.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 apps/minting-service/vercel.json diff --git a/apps/minting-service/vercel.json b/apps/minting-service/vercel.json new file mode 100644 index 000000000..7bccdd339 --- /dev/null +++ b/apps/minting-service/vercel.json @@ -0,0 +1,11 @@ +{ + "installCommand": "cd ../.. && npm ci", + "buildCommand": "cd ../.. && npx tsc --noEmit -p apps/minting-service/tsconfig.app.json", + "framework": null, + "functions": { + "api/*.ts": { + "runtime": "nodejs20.x", + "maxDuration": 10 + } + } +} From 7907e78756bac6238c6c802e56ea51136da32b6e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 15:09:56 -0700 Subject: [PATCH 49/51] feat(minting-service): add manual re-mint CLI --- apps/minting-service/project.json | 7 ++ apps/minting-service/scripts/remint.spec.ts | 114 ++++++++++++++++++ apps/minting-service/scripts/remint.ts | 122 ++++++++++++++++++++ apps/minting-service/tsconfig.app.json | 4 +- apps/minting-service/vite.config.mts | 2 +- 5 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 apps/minting-service/scripts/remint.spec.ts create mode 100644 apps/minting-service/scripts/remint.ts diff --git a/apps/minting-service/project.json b/apps/minting-service/project.json index f423c85fd..09be25624 100644 --- a/apps/minting-service/project.json +++ b/apps/minting-service/project.json @@ -9,6 +9,13 @@ "test": { "executor": "@nx/vitest:test", "options": { "configFile": "apps/minting-service/vite.config.mts" } + }, + "remint": { + "executor": "nx:run-commands", + "options": { + "command": "tsx scripts/remint.ts", + "cwd": "apps/minting-service" + } } } } diff --git a/apps/minting-service/scripts/remint.spec.ts b/apps/minting-service/scripts/remint.spec.ts new file mode 100644 index 000000000..c6d46a647 --- /dev/null +++ b/apps/minting-service/scripts/remint.spec.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { parseArgs, runRemint, type RemintDeps } from './remint.js'; +import type { License } from '@cacheplane/db'; + +function makeLicense(overrides: Partial = {}): License { + return { + id: 'lic_1', + stripeCustomerId: 'cus_1', + stripeSubscriptionId: 'sub_1', + customerEmail: 'a@example.com', + tier: 'developer-seat', + seats: 3, + expiresAt: new Date('2027-01-01T00:00:00Z'), + revokedAt: null, + lastToken: 'TOKEN.SIG', + issuedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as License; +} + +function makeDeps(overrides: Partial = {}): RemintDeps { + return { + db: {} as any, + getLicense: vi.fn().mockResolvedValue(makeLicense()), + updateLicenseToken: vi.fn().mockImplementation(async (_db, _id, token) => + makeLicense({ lastToken: token, issuedAt: new Date() }), + ), + mintToken: vi.fn().mockResolvedValue('NEW.TOKEN'), + sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_1' }), + resendApiKey: 're_test', + emailFrom: 'from@example.com', + privateKeyHex: 'a'.repeat(64), + ...overrides, + }; +} + +describe('parseArgs', () => { + it('parses --sub', () => { + expect(parseArgs(['--sub=sub_abc']).sub).toBe('sub_abc'); + }); + + it('defaults dryRun and newToken to false, to to undefined', () => { + const a = parseArgs(['--sub=sub_x']); + expect(a.dryRun).toBe(false); + expect(a.newToken).toBe(false); + expect(a.to).toBeUndefined(); + }); + + it('recognises --dry-run, --new-token, --to', () => { + const a = parseArgs(['--sub=sub_x', '--dry-run', '--new-token', '--to=b@b.c']); + expect(a.dryRun).toBe(true); + expect(a.newToken).toBe(true); + expect(a.to).toBe('b@b.c'); + }); + + it('throws if --sub is missing', () => { + expect(() => parseArgs([])).toThrow(/--sub/); + }); +}); + +describe('runRemint', () => { + it('sends email with existing token by default', async () => { + const deps = makeDeps(); + const result = await runRemint({ sub: 'sub_1', dryRun: false, newToken: false }, deps); + expect(deps.mintToken).not.toHaveBeenCalled(); + expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); + const sendArg = (deps.sendLicenseEmail as unknown as { mock: { calls: any[][] } }).mock.calls[0][0]; + expect(sendArg.to).toBe('a@example.com'); + expect(sendArg.vars.token).toBe('TOKEN.SIG'); + expect(result.sent).toBe(true); + }); + + it('overrides destination with --to', async () => { + const deps = makeDeps(); + await runRemint({ sub: 'sub_1', dryRun: false, newToken: false, to: 'new@b.c' }, deps); + const sendArg = (deps.sendLicenseEmail as unknown as { mock: { calls: any[][] } }).mock.calls[0][0]; + expect(sendArg.to).toBe('new@b.c'); + }); + + it('mints and persists a new token with --new-token', async () => { + const deps = makeDeps(); + await runRemint({ sub: 'sub_1', dryRun: false, newToken: true }, deps); + expect(deps.mintToken).toHaveBeenCalledTimes(1); + expect(deps.updateLicenseToken).toHaveBeenCalledTimes(1); + const sendArg = (deps.sendLicenseEmail as unknown as { mock: { calls: any[][] } }).mock.calls[0][0]; + expect(sendArg.vars.token).toBe('NEW.TOKEN'); + }); + + it('does not send email with --dry-run', async () => { + const deps = makeDeps(); + const result = await runRemint({ sub: 'sub_1', dryRun: true, newToken: false }, deps); + expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); + expect(result.sent).toBe(false); + expect(result.preview).toBeDefined(); + }); + + it('refuses when license is revoked', async () => { + const deps = makeDeps({ + getLicense: vi.fn().mockResolvedValue(makeLicense({ revokedAt: new Date() })), + }); + await expect( + runRemint({ sub: 'sub_1', dryRun: false, newToken: false }, deps), + ).rejects.toThrow(/revoked/); + }); + + it('throws when license does not exist', async () => { + const deps = makeDeps({ getLicense: vi.fn().mockResolvedValue(null) }); + await expect( + runRemint({ sub: 'sub_nope', dryRun: false, newToken: false }, deps), + ).rejects.toThrow(/sub_nope/); + }); +}); diff --git a/apps/minting-service/scripts/remint.ts b/apps/minting-service/scripts/remint.ts new file mode 100644 index 000000000..c37960496 --- /dev/null +++ b/apps/minting-service/scripts/remint.ts @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + createDb, + getLicense, + updateLicenseToken, + type Db, + type License, +} from '@cacheplane/db'; +import { loadEnv } from '../src/lib/env.js'; +import { mintToken } from '../src/lib/sign.js'; +import { sendLicenseEmail, renderLicenseEmail, type RenderedEmail } from '../src/lib/email.js'; + +export interface RemintArgs { + sub: string; + dryRun: boolean; + newToken: boolean; + to?: string; +} + +export interface RemintDeps { + db: Db; + getLicense: (db: Db, subId: string) => Promise; + updateLicenseToken: (db: Db, id: string, token: string) => Promise; + mintToken: typeof mintToken; + sendLicenseEmail: typeof sendLicenseEmail; + resendApiKey: string; + emailFrom: string; + privateKeyHex: string; +} + +export interface RemintResult { + sent: boolean; + preview?: RenderedEmail; +} + +export function parseArgs(argv: string[]): RemintArgs { + const out: Partial = { dryRun: false, newToken: false }; + for (const arg of argv) { + if (arg.startsWith('--sub=')) out.sub = arg.slice('--sub='.length); + else if (arg === '--dry-run') out.dryRun = true; + else if (arg === '--new-token') out.newToken = true; + else if (arg.startsWith('--to=')) out.to = arg.slice('--to='.length); + } + if (!out.sub) throw new Error('remint: --sub= is required'); + return out as RemintArgs; +} + +export async function runRemint(args: RemintArgs, deps: RemintDeps): Promise { + const license = await deps.getLicense(deps.db, args.sub); + if (!license) throw new Error(`remint: no license found for subscription ${args.sub}`); + if (license.revokedAt) { + throw new Error(`remint: license is revoked (revoked_at=${license.revokedAt.toISOString()}); refusing to resend`); + } + + let token = license.lastToken; + if (args.newToken) { + token = await deps.mintToken( + { + stripeCustomerId: license.stripeCustomerId, + tier: license.tier as 'developer-seat' | 'app-deployment', + seats: license.seats, + expiresAt: license.expiresAt, + }, + deps.privateKeyHex, + ); + await deps.updateLicenseToken(deps.db, license.id, token); + } + + const to = args.to ?? license.customerEmail; + const vars = { + tier: license.tier as 'developer-seat' | 'app-deployment', + seats: license.seats, + token, + expiresAt: license.expiresAt, + }; + + if (args.dryRun) { + return { sent: false, preview: renderLicenseEmail(vars) }; + } + + await deps.sendLicenseEmail({ + resendApiKey: deps.resendApiKey, + from: deps.emailFrom, + to, + vars, + }); + return { sent: true }; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const env = loadEnv(); + const db = createDb(env.DATABASE_URL); + try { + const result = await runRemint(args, { + db, + getLicense, + updateLicenseToken, + mintToken, + sendLicenseEmail, + resendApiKey: env.RESEND_API_KEY, + emailFrom: env.EMAIL_FROM, + privateKeyHex: env.LICENSE_SIGNING_PRIVATE_KEY_HEX, + }); + if (result.sent) { + console.log(`Sent to ${args.to ?? '(license email)'} for subscription ${args.sub}`); + } else if (result.preview) { + console.log('--- DRY RUN ---'); + console.log('Subject:', result.preview.subject); + console.log(result.preview.text); + } + } finally { + await db.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/apps/minting-service/tsconfig.app.json b/apps/minting-service/tsconfig.app.json index 55138b74e..03e3dee32 100644 --- a/apps/minting-service/tsconfig.app.json +++ b/apps/minting-service/tsconfig.app.json @@ -10,6 +10,6 @@ "declarationMap": false, "emitDeclarationOnly": false }, - "include": ["src/**/*.ts", "api/**/*.ts"], - "exclude": ["src/**/*.spec.ts", "api/**/*.spec.ts"] + "include": ["src/**/*.ts", "api/**/*.ts", "scripts/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "api/**/*.spec.ts", "scripts/**/*.spec.ts"] } diff --git a/apps/minting-service/vite.config.mts b/apps/minting-service/vite.config.mts index 7b289afd6..1acfdde53 100644 --- a/apps/minting-service/vite.config.mts +++ b/apps/minting-service/vite.config.mts @@ -6,7 +6,7 @@ export default defineConfig({ test: { environment: 'node', globals: true, - include: ['src/**/*.spec.ts', 'api/**/*.spec.ts'], + include: ['src/**/*.spec.ts', 'api/**/*.spec.ts', 'scripts/**/*.spec.ts'], passWithNoTests: true, }, }); From 7e2fd6276dbf8a1dc37b657e8c8c7b5b5caf2f0a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 15:13:19 -0700 Subject: [PATCH 50/51] docs(minting-service): add operator runbook --- apps/minting-service/README.md | 152 +++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 apps/minting-service/README.md diff --git a/apps/minting-service/README.md b/apps/minting-service/README.md new file mode 100644 index 000000000..2b3ed7f23 --- /dev/null +++ b/apps/minting-service/README.md @@ -0,0 +1,152 @@ +# @cacheplane/minting-service + +License minting service for Cacheplane. Receives Stripe webhooks, signs +Ed25519 license tokens via `@cacheplane/licensing`, persists them to +Postgres via `@cacheplane/db`, and emails them to customers via Resend. + +**Design spec:** `docs/superpowers/specs/2026-04-20-minting-service-design.md` + +## What this service does + +- Handles Stripe events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`. +- Mints a signed license token per active subscription. +- Emails the token to the customer. +- Stores license state keyed on `stripe_subscription_id`. + +## What this service does NOT do + +- No customer portal / self-service resend (run the CLI — see below). +- No pricing/checkout UI (handled on the website — Plan 3). +- No automated key rotation (requires library republish). + +## Local development + +1. Install Docker (for local Postgres) and the Stripe CLI. +2. From the repo root: + ```bash + docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16 + cp apps/minting-service/.env.example apps/minting-service/.env + # Edit .env with local values; for LICENSE_SIGNING_PRIVATE_KEY_HEX + # generate a keypair (see "Generating a signing key" below). + DATABASE_URL=postgres://postgres:dev@localhost:5432/postgres npx nx run db:db:migrate + cd apps/minting-service && vercel dev + ``` +3. In another terminal: + ```bash + stripe listen --forward-to localhost:3000/api/stripe-webhook + # Copy the printed whsec_... into apps/minting-service/.env as STRIPE_WEBHOOK_SECRET + ``` +4. Trigger events: + ```bash + stripe trigger checkout.session.completed + ``` + +## Generating a signing key + +```bash +node -e "import('@noble/ed25519').then(async (e) => { + const sk = e.utils.randomPrivateKey(); + const pk = await e.getPublicKeyAsync(sk); + console.log('priv (LICENSE_SIGNING_PRIVATE_KEY_HEX):', Buffer.from(sk).toString('hex')); + console.log('pub (LICENSE_PUBLIC_KEY in @cacheplane/licensing):', Buffer.from(pk).toString('hex')); +});" +``` + +Store the private key in the Vercel env as `LICENSE_SIGNING_PRIVATE_KEY_HEX` +marked "Sensitive". Back up to a password manager. The **public** key must be +baked into `libs/licensing/src/lib/license-public-key.generated.ts` and the +lib republished. + +## Environment variables + +All listed in `.env.example`. Validated at process start by `src/lib/env.ts`. +Missing/malformed vars throw with a descriptive message. + +## Deployment + +1. Ensure schema is up to date: + ```bash + DATABASE_URL= npx nx run db:db:migrate + ``` +2. Push. Vercel deploys from `main` automatically for production and per PR + for previews. +3. Smoke test preview: + ```bash + curl https://.vercel.app/api/health # {"ok":true} + stripe trigger checkout.session.completed # (against preview webhook endpoint) + ``` + +## Operator runbook + +### Re-mint a license + +```bash +nx run minting-service:remint --sub=sub_1234 [--dry-run] [--to=new@email.com] [--new-token] +``` + +- `--sub=` (required): which license to resend. +- `--dry-run`: print what would be sent; don't call Resend. +- `--to=`: override destination (use after an email bounce). +- `--new-token`: re-sign a fresh token (updates `last_token` + `issued_at`). + Default is to re-send the existing `last_token`. + +Revoked licenses are refused. + +### Look up a customer's license + +```bash +psql $DATABASE_URL -c "SELECT * FROM licenses WHERE customer_email = 'x@y.z'" +``` + +### Manually revoke + +```sql +UPDATE licenses SET revoked_at = now() WHERE stripe_subscription_id = 'sub_xxx'; +``` + +Prefer canceling the Stripe subscription — this bypasses the normal webhook +flow and won't un-revoke on a new subscription. + +### Un-revoke after accidental revoke + +```sql +UPDATE licenses SET revoked_at = NULL WHERE stripe_subscription_id = 'sub_xxx'; +``` + +Then `nx run minting-service:remint --sub=sub_xxx --new-token` to issue a +fresh token. + +### Retry a failed webhook + +1. In the Stripe dashboard → Developers → Webhooks, find the failed event `evt_xxx`. +2. Check if we recorded it: + ```sql + SELECT * FROM processed_events WHERE stripe_event_id = 'evt_xxx'; + ``` +3. If present: `DELETE FROM processed_events WHERE stripe_event_id = 'evt_xxx';` +4. Click "Resend" on the event in Stripe. + +### Rotate the signing key (manual, v1) + +Current design requires a library republish (no multi-key verification). +Steps: + +1. Generate new keypair (see "Generating a signing key"). +2. Update `libs/licensing/src/lib/license-public-key.generated.ts` with the new public key. +3. Republish `@cacheplane/licensing` (minor version bump). +4. Update `LICENSE_SIGNING_PRIVATE_KEY_HEX` in Vercel env. +5. Deploy minting service. +6. Batch-remint all active licenses: + ```bash + # Example: loop over all non-revoked subs and re-mint with fresh tokens + psql $DATABASE_URL -t -c "SELECT stripe_subscription_id FROM licenses WHERE revoked_at IS NULL" | \ + xargs -I{} nx run minting-service:remint --sub={} --new-token + ``` + +All existing tokens become unverifiable once customers upgrade the library. + +## Why this repo is public + +The private signing key lives only in Vercel env. Everything else — schema, +webhook logic, re-mint flow — is plumbing. Possession of the key is the only +thing that matters. Documenting the process openly is a transparency plus. From 1d394107db21c90fb5bd28171219487eb55a4c37 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 20 Apr 2026 16:41:40 -0700 Subject: [PATCH 51/51] fix(licensing): make library browser-safe for Angular consumers The licensing lib was using Node-only APIs (`Buffer`, `process`) in its base64url helpers and telemetry opt-out check, which caused Angular compilers (demo, cockpit-*, website-embedded bundles) to fail with `TS2591: Cannot find name 'Buffer'/'process'`. - license-token.ts: swap `Buffer.from(b64, 'base64')` for `atob` + Uint8Array conversion. - sign-license.ts: swap `Buffer.from(bytes).toString('base64')` for `btoa` + String.fromCharCode conversion. - telemetry.ts: read `process.env` via `globalThis` so the bare `process` identifier is never referenced. - license-token.ts + telemetry.ts: switch Record dot-access to bracket-access to satisfy Angular's `noPropertyAccessFromIndexSignature`. No behavior change: atob/btoa are available in Node 16+ and all browsers; all 37 licensing unit tests and 42 minting-service tests still pass; website, demo, and cockpit Angular builds now succeed. Co-Authored-By: Claude Opus 4 --- libs/licensing/src/lib/license-token.ts | 27 ++++++++++++++++--------- libs/licensing/src/lib/sign-license.ts | 7 +++++-- libs/licensing/src/lib/telemetry.ts | 15 ++++++++------ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/libs/licensing/src/lib/license-token.ts b/libs/licensing/src/lib/license-token.ts index 25f5949aa..6a6486544 100644 --- a/libs/licensing/src/lib/license-token.ts +++ b/libs/licensing/src/lib/license-token.ts @@ -32,7 +32,12 @@ function base64UrlToBytes(s: string): Uint8Array | null { // base64url -> base64 const pad = '='.repeat((4 - (s.length % 4)) % 4); const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/'); - return Uint8Array.from(Buffer.from(b64, 'base64')); + // atob is available in Node 16+ and all browsers; avoids Buffer so this + // module is safe to bundle for browser/Angular consumers. + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; } catch { return null; } @@ -40,16 +45,20 @@ function base64UrlToBytes(s: string): Uint8Array | null { function isLicenseClaims(value: unknown): value is LicenseClaims { if (!value || typeof value !== 'object') return false; + // Bracket access required because `Record` is an index + // signature; Angular consumers enable `noPropertyAccessFromIndexSignature`. const v = value as Record; + const tier = v['tier']; + const seats = v['seats']; return ( - typeof v.sub === 'string' && - (v.tier === 'developer-seat' || - v.tier === 'app-deployment' || - v.tier === 'enterprise') && - typeof v.iat === 'number' && - typeof v.exp === 'number' && - typeof v.seats === 'number' && - v.seats >= 1 + typeof v['sub'] === 'string' && + (tier === 'developer-seat' || + tier === 'app-deployment' || + tier === 'enterprise') && + typeof v['iat'] === 'number' && + typeof v['exp'] === 'number' && + typeof seats === 'number' && + seats >= 1 ); } diff --git a/libs/licensing/src/lib/sign-license.ts b/libs/licensing/src/lib/sign-license.ts index dad268daa..07dfee723 100644 --- a/libs/licensing/src/lib/sign-license.ts +++ b/libs/licensing/src/lib/sign-license.ts @@ -3,8 +3,11 @@ import * as ed from '@noble/ed25519'; import type { LicenseClaims } from './license-token.js'; function bytesToBase64Url(bytes: Uint8Array): string { - return Buffer.from(bytes) - .toString('base64') + // btoa is available in Node 16+ and all browsers; avoids Buffer so this + // module is safe to bundle for browser/Angular consumers. + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); diff --git a/libs/licensing/src/lib/telemetry.ts b/libs/licensing/src/lib/telemetry.ts index ead2a9dd7..eabd59a70 100644 --- a/libs/licensing/src/lib/telemetry.ts +++ b/libs/licensing/src/lib/telemetry.ts @@ -19,13 +19,16 @@ export interface CreateTelemetryClientOptions { } function isOptedOut(): boolean { - const envFlag = - typeof process !== 'undefined' && process.env - ? process.env.CACHEPLANE_TELEMETRY - : undefined; + // Access `process` via globalThis so this module bundles cleanly for + // browser/Angular targets where `process` is not a declared global. + const g = globalThis as { + process?: { env?: Record }; + CACHEPLANE_TELEMETRY?: unknown; + }; + const envFlag = g.process?.env?.['CACHEPLANE_TELEMETRY']; if (envFlag === '0' || envFlag === 'false') return true; - const g = (globalThis as Record).CACHEPLANE_TELEMETRY; - if (g === false || g === 0 || g === '0') return true; + const override = g.CACHEPLANE_TELEMETRY; + if (override === false || override === 0 || override === '0') return true; return false; }