Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
1a0b648
docs: add license verification library implementation plan
blove Apr 20, 2026
3c825f1
feat(licensing): scaffold @cacheplane/licensing library
blove Apr 20, 2026
17ae35f
feat(licensing): add license token schema and parser
blove Apr 20, 2026
5f0464d
feat(licensing): add offline ed25519 license verification
blove Apr 20, 2026
952d067
feat(licensing): add license status evaluation with grace window
blove Apr 20, 2026
192cdc7
feat(licensing): add nag UX with per-package dedupe
blove Apr 20, 2026
5e3787c
feat(licensing): add non-blocking telemetry client with opt-out
blove Apr 20, 2026
b9aac38
feat(licensing): embed ed25519 public key at build time
blove Apr 20, 2026
f8d816b
feat(licensing): add runLicenseCheck orchestrator and public API
blove Apr 20, 2026
6cb64a6
docs(licensing): add README and shared test fixtures
blove Apr 20, 2026
c407e7e
docs: revise T10-T12 integration tasks with __licensePublicKey hook
blove Apr 20, 2026
fd95400
feat(agent): run license check at provider init
blove Apr 20, 2026
dd41f24
docs: call out baseUrl removal in T11 render integration
blove Apr 20, 2026
ad8a5e7
feat(render): run license check at provider init
blove Apr 20, 2026
6addcd1
feat(chat): run license check at provider init
blove Apr 20, 2026
4c19e29
fix(licensing): make library dist ESM-loadable
blove Apr 20, 2026
0deb6df
refactor(licensing): hoist inferNoncommercial into the licensing lib
blove Apr 20, 2026
9c3811a
chore: clear pre-existing lint errors surfaced by T13 sweep
blove Apr 20, 2026
ba081e9
docs: add minting service design spec
blove Apr 20, 2026
7e41fce
docs: align minting spec tier values with shipped LicenseTier type
blove Apr 20, 2026
ac5ef08
docs: add minting service implementation plan
blove Apr 20, 2026
bb2d625
feat(licensing): add signLicense for minting signed license tokens
blove Apr 20, 2026
205a39b
refactor(licensing): consolidate duplicate signLicense helper
blove Apr 20, 2026
de34a3d
fix(licensing): repair fixtures.ts import after signLicense consolida…
blove Apr 20, 2026
eea577c
feat(db): scaffold @cacheplane/db lib
blove Apr 20, 2026
15dcbd8
feat(db): add Drizzle client factory
blove Apr 20, 2026
2f1e7a7
docs(db): explain why prepare:false is required
blove Apr 20, 2026
aea37a5
feat(db): add licenses table schema
blove Apr 20, 2026
3efcc0c
chore: migrate @nx/vite:test to @nx/vitest:test
blove Apr 20, 2026
3d46420
feat(db): add processed_events table schema
blove Apr 20, 2026
9dc0ad3
feat(db): configure drizzle-kit and generate initial migration
blove Apr 20, 2026
42c5a44
feat(db): add testcontainers-based integration test helpers
blove Apr 20, 2026
00c0cb7
feat(db): add processed-events queries with idempotency
blove Apr 20, 2026
97f2391
feat(db): add license queries (upsert, get, revoke, updateToken, byEm…
blove Apr 20, 2026
6d24841
feat(minting-service): scaffold Nx Node app
blove Apr 20, 2026
cfd6cd3
feat(minting-service): add runtime deps and .env.example
blove Apr 20, 2026
6091cb4
feat(minting-service): add env var validation
blove Apr 20, 2026
ff392a0
feat(minting-service): add tier extraction and seat computation
blove Apr 20, 2026
0871116
feat(minting-service): add mintToken wrapper over @cacheplane/licensing
blove Apr 20, 2026
c1be771
feat(minting-service): add license email renderer and Resend wrapper
blove Apr 20, 2026
351f0d0
feat(minting-service): add Stripe SDK singleton
blove Apr 20, 2026
3eea711
feat(minting-service): add handleEvent dispatcher with idempotency + …
blove Apr 20, 2026
8459527
feat(minting-service): implement handleCheckoutCompleted
blove Apr 20, 2026
43f86e5
feat(minting-service): implement handleSubscriptionUpdated with mater…
blove Apr 20, 2026
740fcc2
test(minting-service): lock in handleSubscriptionDeleted contract
blove Apr 20, 2026
d65b2e5
feat(minting-service): add /api/health probe
blove Apr 20, 2026
e1ce408
feat(minting-service): add /api/stripe-webhook endpoint
blove Apr 20, 2026
c8e80ca
feat(minting-service): add Vercel deployment config
blove Apr 20, 2026
7907e78
feat(minting-service): add manual re-mint CLI
blove Apr 20, 2026
7e2fd62
docs(minting-service): add operator runbook
blove Apr 20, 2026
1d39410
fix(licensing): make library browser-safe for Angular consumers
blove Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion apps/cockpit/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
}
},
"test": {
"executor": "@nx/vite:test",
"executor": "@nx/vitest:test",
"options": {
"configFile": "apps/cockpit/vite.config.mts"
}
Expand Down
16 changes: 16 additions & 0 deletions apps/minting-service/.env.example
Original file line number Diff line number Diff line change
@@ -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
152 changes: 152 additions & 0 deletions apps/minting-service/README.md
Original file line number Diff line number Diff line change
@@ -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=<prod-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://<preview>.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=<stripe_subscription_id>` (required): which license to resend.
- `--dry-run`: print what would be sent; don't call Resend.
- `--to=<email>`: 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.
6 changes: 6 additions & 0 deletions apps/minting-service/api/health.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
79 changes: 79 additions & 0 deletions apps/minting-service/api/stripe-webhook.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer> {
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<void> {
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();
}
}
3 changes: 3 additions & 0 deletions apps/minting-service/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import baseConfig from '../../eslint.config.mjs';

export default [...baseConfig];
12 changes: 12 additions & 0 deletions apps/minting-service/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
21 changes: 21 additions & 0 deletions apps/minting-service/project.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Loading
Loading