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/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/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/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. 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/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(); + } +} 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..35f92e80b --- /dev/null +++ b/apps/minting-service/package.json @@ -0,0 +1,12 @@ +{ + "name": "@cacheplane/minting-service", + "version": "0.0.1", + "type": "module", + "private": true, + "dependencies": { + "drizzle-orm": "^0.45.2", + "postgres": "^3.4.9", + "resend": "^6.10.0", + "stripe": "^22.0.2" + } +} diff --git a/apps/minting-service/project.json b/apps/minting-service/project.json new file mode 100644 index 000000000..09be25624 --- /dev/null +++ b/apps/minting-service/project.json @@ -0,0 +1,21 @@ +{ + "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" } + }, + "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/src/.gitkeep b/apps/minting-service/src/.gitkeep new file mode 100644 index 000000000..e69de29bb 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 }; +} 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), + }; +} 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..4f9234a28 --- /dev/null +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -0,0 +1,268 @@ +// 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 { + 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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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(); + }); +}); diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts new file mode 100644 index 000000000..dfbb9eba4 --- /dev/null +++ b/apps/minting-service/src/lib/handlers.ts @@ -0,0 +1,201 @@ +// 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'; +import { extractTier, computeSeats } from './tier.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 { + 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( + 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 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( + sub: Stripe.Subscription, + deps: HandlerDeps, +): Promise { + await deps.revokeLicense(deps.db, sub.id); +} 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; +} diff --git a/apps/minting-service/src/lib/stripe.ts b/apps/minting-service/src/lib/stripe.ts new file mode 100644 index 000000000..107095b75 --- /dev/null +++ b/apps/minting-service/src/lib/stripe.ts @@ -0,0 +1,19 @@ +// 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) { + // 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/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; +} diff --git a/apps/minting-service/tsconfig.app.json b/apps/minting-service/tsconfig.app.json new file mode 100644 index 000000000..03e3dee32 --- /dev/null +++ b/apps/minting-service/tsconfig.app.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "outDir": "../../dist/apps/minting-service", + "composite": false, + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false + }, + "include": ["src/**/*.ts", "api/**/*.ts", "scripts/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "api/**/*.spec.ts", "scripts/**/*.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/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 + } + } +} diff --git a/apps/minting-service/vite.config.mts b/apps/minting-service/vite.config.mts new file mode 100644 index 000000000..1acfdde53 --- /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', 'scripts/**/*.spec.ts'], + passWithNoTests: true, + }, +}); 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..69c99d282 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-license-verification-library.md @@ -0,0 +1,2234 @@ +# 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:** +- 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/testing`. +- **Do NOT modify `libs/licensing/src/lib/testing/keypair.ts`** in this task. `generateKeyPair()` must stay non-deterministic. +- **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. +- **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: Create the testing subpath entry** + +Create `libs/licensing/src/testing.ts`: + +```ts +// 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 +// 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' })], + }); + 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, + // @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); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx nx test agent` +Expected: FAIL — `license` / `__licensePublicKey` not known properties of `AgentConfig`, and `@cacheplane/licensing` doesn't yet export the reset helpers. + +- [ ] **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 }; + /** + * @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 { + // 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. + */ +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: config.__licensePublicKey ?? 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. + +**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` +Expected: build succeeds. + +- [ ] **Step 8: Commit** + +```bash +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" +``` + +--- + +## 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:** + +- 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`, `libs/licensing/project.json`, or the `@cacheplane/licensing/testing` subpath wiring set up in T10. +- Mirror agent's `__licensePublicKey` override on `RenderConfig` for symmetry. +- 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** + +`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'; +import { + __resetRunLicenseCheckStateForTests, + __resetNagStateForTests, +} from '@cacheplane/licensing/testing'; + +describe('provideRender', () => { + beforeEach(() => { + __resetRunLicenseCheckStateForTests(); + __resetNagStateForTests(); + 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 }; + /** + * @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** + +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 { + // 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 }, + ]); +} +``` + +- [ ] **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. + +**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` +Expected: build succeeds. + +- [ ] **Step 7: Commit** + +```bash +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" +``` + +--- + +## 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` + +**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`: + +```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'; +import { + __resetRunLicenseCheckStateForTests, + __resetNagStateForTests, +} from '@cacheplane/licensing/testing'; + +describe('provideChat', () => { + beforeEach(() => { + __resetRunLicenseCheckStateForTests(); + __resetNagStateForTests(); + 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 { + // 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; + /** 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 }; + /** + * @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 }, + ]); +} +``` + +- [ ] **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. + +**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` +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. 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. 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..6299fed7a --- /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` | `'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` | +| `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 (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 })`. +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.
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/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/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/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..49fecce8c 100644
--- a/libs/agent/src/lib/agent.provider.ts
+++ b/libs/agent/src/lib/agent.provider.ts
@@ -1,7 +1,23 @@
 // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
 import { InjectionToken, Provider } from '@angular/core';
+import {
+  runLicenseCheck,
+  LICENSE_PUBLIC_KEY,
+  inferNoncommercial,
+} 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 +27,38 @@ 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');
 
 /**
  * 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/chat/package.json b/libs/chat/package.json
index e464267fe..e4627a4f7 100644
--- a/libs/chat/package.json
+++ b/libs/chat/package.json
@@ -5,6 +5,8 @@
     "@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",
     "@cacheplane/partial-json": "^0.0.1",
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/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/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..2c710c734 100644 --- a/libs/chat/src/lib/provide-chat.ts +++ b/libs/chat/src/lib/provide-chat.ts @@ -1,7 +1,20 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import { + runLicenseCheck, + LICENSE_PUBLIC_KEY, + inferNoncommercial, +} 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'; + export interface ChatConfig { /** Default render registry for generative UI components. */ renderRegistry?: AngularRegistry; @@ -9,11 +22,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 }, ]); 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/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/package.json b/libs/db/package.json new file mode 100644 index 000000000..6d6cac0e2 --- /dev/null +++ b/libs/db/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cacheplane/db", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false, + "publishConfig": { + "access": "public", + "provenance": true + }, + "peerDependencies": { + "drizzle-orm": "^0.45.0", + "postgres": "^3.4.0" + } +} diff --git a/libs/db/project.json b/libs/db/project.json new file mode 100644 index 000000000..bcb310fef --- /dev/null +++ b/libs/db/project.json @@ -0,0 +1,37 @@ +{ + "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/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" + } + } + } +} diff --git a/libs/db/src/index.ts b/libs/db/src/index.ts new file mode 100644 index 000000000..641df0234 --- /dev/null +++ b/libs/db/src/index.ts @@ -0,0 +1,13 @@ +// 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'; 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..13950310d --- /dev/null +++ b/libs/db/src/lib/client.ts @@ -0,0 +1,23 @@ +// 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'); + } + // 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(); + return db; +} 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]; +} 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 new file mode 100644 index 000000000..1b87e200e --- /dev/null +++ b/libs/db/src/lib/queries/test-helpers.ts @@ -0,0 +1,35 @@ +// 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 { + // 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 }); + + 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/src/lib/schema/index.ts b/libs/db/src/lib/schema/index.ts new file mode 100644 index 000000000..28916a88d --- /dev/null +++ b/libs/db/src/lib/schema/index.ts @@ -0,0 +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/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; 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; 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..5afdd0cbe --- /dev/null +++ b/libs/db/vite.config.mts @@ -0,0 +1,14 @@ +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, + testTimeout: 60_000, + hookTimeout: 120_000, + }, +}); 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/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/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/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..579562cf9 --- /dev/null +++ b/libs/licensing/project.json @@ -0,0 +1,31 @@ +{ + "name": "licensing", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/licensing/src", + "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", + "main": "libs/licensing/src/index.ts", + "tsConfig": "libs/licensing/tsconfig.lib.json" + } + }, + "lint": { "executor": "@nx/eslint:lint" }, + "test": { + "executor": "@nx/vitest: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/index.ts b/libs/licensing/src/index.ts new file mode 100644 index 000000000..a3382250e --- /dev/null +++ b/libs/licensing/src/index.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +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.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 { 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/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..d2f0fdffd --- /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.js'; +import type { VerifyResult } from './verify-license.js'; + +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 }; +} 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/licensing/src/lib/license-public-key.ts b/libs/licensing/src/lib/license-public-key.ts new file mode 100644 index 000000000..50c2d1861 --- /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.js'; + +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/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..6a6486544 --- /dev/null +++ b/libs/licensing/src/lib/license-token.ts @@ -0,0 +1,88 @@ +// 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, '/'); + // 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; + } +} + +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' && + (tier === 'developer-seat' || + tier === 'app-deployment' || + tier === 'enterprise') && + typeof v['iat'] === 'number' && + typeof v['exp'] === 'number' && + typeof seats === 'number' && + 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 }; +} 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..f816965c9 --- /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.js'; + +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(); +} 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..76927c3db --- /dev/null +++ b/libs/licensing/src/lib/run-license-check.spec.ts @@ -0,0 +1,118 @@ +// 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 { signLicense } from './sign-license'; +import { generateKeyPair, 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..40a7e20f3 --- /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.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. */ + 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(); +} 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..07dfee723 --- /dev/null +++ b/libs/licensing/src/lib/sign-license.ts @@ -0,0 +1,29 @@ +// 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 { + // 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(/=+$/, ''); +} + +/** + * 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)}`; +} 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..eabd59a70 --- /dev/null +++ b/libs/licensing/src/lib/telemetry.ts @@ -0,0 +1,74 @@ +// 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 { + // 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 override = g.CACHEPLANE_TELEMETRY; + if (override === false || override === 0 || override === '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. + } + }, + }; +} diff --git a/libs/licensing/src/lib/testing/fixtures.ts b/libs/licensing/src/lib/testing/fixtures.ts new file mode 100644 index 000000000..572499489 --- /dev/null +++ b/libs/licensing/src/lib/testing/fixtures.ts @@ -0,0 +1,30 @@ +// 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 } from '../sign-license'; +import { 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 }; +} diff --git a/libs/licensing/src/lib/testing/keypair.ts b/libs/licensing/src/lib/testing/keypair.ts new file mode 100644 index 000000000..834e7c725 --- /dev/null +++ b/libs/licensing/src/lib/testing/keypair.ts @@ -0,0 +1,14 @@ +// 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'; + +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 }; +} 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..0849a9f4f --- /dev/null +++ b/libs/licensing/src/lib/verify-license.spec.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { beforeAll, describe, it, expect } from 'vitest'; +import { verifyLicense } from './verify-license'; +import { signLicense } from './sign-license'; +import { generateKeyPair, 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..ff07fc5a5 --- /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.js'; + +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 }; +} diff --git a/libs/licensing/src/testing.ts b/libs/licensing/src/testing.ts new file mode 100644 index 000000000..5bf26cfee --- /dev/null +++ b/libs/licensing/src/testing.ts @@ -0,0 +1,9 @@ +// 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 } 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'; 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..0186ab08c --- /dev/null +++ b/libs/licensing/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", "src/lib/testing/**", "src/testing.ts"] +} 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/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/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/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/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..17813a79c 100644 --- a/libs/render/src/lib/provide-render.ts +++ b/libs/render/src/lib/provide-render.ts @@ -1,10 +1,33 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import { + runLicenseCheck, + LICENSE_PUBLIC_KEY, + inferNoncommercial, +} 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'; + 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/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()', () => { 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, 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" } diff --git a/package-lock.json b/package-lock.json index d73b0196f..9bea77c80 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/*", @@ -22,15 +23,19 @@ "@langchain/core": "^1.1.33", "@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", "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", @@ -59,12 +64,16 @@ "@swc/core": "1.15.8", "@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", + "@vercel/node": "^5.7.12", "@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", @@ -78,6 +87,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", @@ -104,6 +114,16 @@ "apps/demo-e2e": { "version": "0.0.1" }, + "apps/minting-service": { + "name": "@cacheplane/minting-service", + "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", "dependencies": { @@ -6734,6 +6754,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", @@ -6796,10 +6823,21 @@ "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 }, + "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", @@ -6961,6 +6999,66 @@ "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/@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", @@ -6992,6 +7090,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", @@ -7663,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", @@ -7732,6 +8287,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", @@ -8688,6 +9295,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", @@ -8857,6 +9567,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", @@ -9310,6 +10031,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", @@ -9588,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", @@ -11228,6 +12007,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", @@ -15796,6 +16584,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", @@ -15812,6 +16611,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", @@ -16159,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", @@ -18710,6 +19594,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", @@ -18891,6 +19785,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", @@ -19066,13 +19983,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": { @@ -19210,6 +20127,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", @@ -19548,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", @@ -20712,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", @@ -20997,6 +22808,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", @@ -21137,6 +23155,30 @@ "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", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "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", @@ -21680,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", @@ -21894,6 +23946,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", @@ -21910,6 +23972,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", @@ -22270,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", @@ -22599,7 +24678,66 @@ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true, - "license": "MIT" + "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", @@ -22684,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", @@ -22715,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", @@ -22881,6 +25039,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", @@ -23502,217 +25744,935 @@ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.8" + } + }, + "node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-port": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz", + "integrity": "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "address": "^1.0.1", + "debug": "4" + }, + "bin": { + "detect": "bin/detect-port.js", + "detect-port": "bin/detect-port.js" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1312386", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", + "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "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", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "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/dependency-graph": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", - "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "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": ">=4" + "node": ">=18" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "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": ">=6" + "node": ">=18" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "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": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, - "license": "Apache-2.0", + "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": ">=8" + "node": ">=18" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-port": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz", - "integrity": "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==", + "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", - "dependencies": { - "address": "^1.0.1", - "debug": "4" - }, - "bin": { - "detect": "bin/detect-port.js", - "detect-port": "bin/detect-port.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 4.0.0" + "node": ">=18" } }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "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", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/devtools-protocol": { - "version": "0.0.1312386", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", - "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", + "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": "BSD-3-Clause" + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "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", - "dependencies": { - "path-type": "^4.0.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "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", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "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", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "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, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } + "license": "MIT", + "optional": true, + "os": [ + "openharmony" ], - "license": "BSD-2-Clause" + "engines": { + "node": ">=18" + } }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "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": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "node": ">=18" } }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "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": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "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", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "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": "BSD-2-Clause", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" + "node": ">=18" } }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "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, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, - "funding": { - "url": "https://dotenvx.com" + "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": { @@ -23775,6 +26735,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", @@ -23796,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", @@ -25062,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", @@ -25337,6 +28365,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", @@ -25770,6 +28828,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", @@ -27588,6 +30659,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", @@ -28434,6 +31521,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", @@ -29337,6 +32470,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", @@ -30991,6 +34131,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", @@ -31006,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", @@ -31089,6 +34246,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", @@ -31633,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", @@ -32331,6 +35508,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", @@ -32434,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", @@ -32623,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", @@ -33848,6 +37050,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", @@ -33915,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", @@ -33983,6 +37214,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", @@ -33993,6 +37280,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", @@ -34375,6 +37687,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", @@ -36356,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", @@ -36580,6 +39928,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", @@ -36597,6 +39952,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", @@ -36800,6 +40195,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", @@ -36827,6 +40238,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", @@ -36881,6 +40306,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", @@ -37321,6 +40763,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", @@ -37416,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", @@ -37776,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", @@ -38076,9 +41575,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" }, @@ -39474,6 +42973,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", @@ -39685,6 +43203,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 9b031eec9..1b91126f6 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", @@ -37,12 +38,16 @@ "@swc/core": "1.15.8", "@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", + "@vercel/node": "^5.7.12", "@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", @@ -56,6 +61,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", @@ -78,15 +84,19 @@ "@langchain/core": "^1.1.33", "@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", "rehype-slug": "^6.0.0", "rxjs": "~7.8.0", - "shiki": "^4.0.2" + "shiki": "^4.0.2", + "stripe": "^22.0.2" }, "nx": { "includedScripts": [], diff --git a/tsconfig.base.json b/tsconfig.base.json index a27b1e954..3f3df86d8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,9 @@ "@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"] }, "skipLibCheck": true,