From 4a2d4f114c9cf93e914fec1aa5153d69c2034550 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 20 May 2026 21:58:35 -0700 Subject: [PATCH 01/15] docs(superpowers): specs for PR B-Stripe + PR C licensing runtime Two parallel sub-PRs that close the @ngaf/chat relicense loop after PRs A + B landed. - PR B-Stripe: Stripe-hosted Checkout for Indie / Developer Seat / App Deployment tiers; idempotent products/prices sync script; /thanks page. One-time 12-month payments. Token minting handled by the existing minting service webhook. - PR C: deploy the existing apps/minting-service to Vercel (threadplane-minting-service project already exists with the prod private key); wire CACHEPLANE_LICENSE_PUBLIC_KEY into publish.yml so published @ngaf/chat ships with the prod public key; fix the runLicenseCheck idempotency bug; document provideChat({license}) and exercise the verify path via one example. Enforcement stays advisory-only; origin allowlist deferred. Both specs include an end-to-end smoke-test runbook that uses examples/chat/angular/ as the verification surface and is explicit about reverting all temporary injections before commit, so the demo stays clean in main. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...0-licensing-verification-runtime-design.md | 149 ++++++++++++++++++ .../2026-05-20-stripe-checkout-design.md | 128 +++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-licensing-verification-runtime-design.md create mode 100644 docs/superpowers/specs/2026-05-20-stripe-checkout-design.md diff --git a/docs/superpowers/specs/2026-05-20-licensing-verification-runtime-design.md b/docs/superpowers/specs/2026-05-20-licensing-verification-runtime-design.md new file mode 100644 index 000000000..b8bedd2a0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-licensing-verification-runtime-design.md @@ -0,0 +1,149 @@ +# PR C — Licensing Verification Runtime + Minting Deploy + +**Status:** Design approved, ready for implementation plan. +**Owner:** apps/minting-service (deploy wiring) + libs/licensing (one bug fix) + .github/workflows (secret injection + deploy step) + docs. +**Affects:** CI workflows, Vercel deploy config, `libs/licensing/src/lib/run-license-check.ts`, `libs/chat/README.md` (one doc section), one example wiring. + +## Goal + +Close the loop on the `@ngaf/chat` relicense: get the production public key into the published bundle, deploy the existing minting service so it actually receives Stripe webhooks and emails license tokens, fix a known idempotency bug in `runLicenseCheck`, document the `provideChat({ license })` convention, and exercise the verify path through one example app so it's part of routine CI. + +This is **mostly an operational PR**. The crypto is already there. The minting code is already there. The keys exist. We're just wiring them. + +## Decisions locked from brainstorm + investigation + +| Topic | Decision | +|---|---| +| Enforcement policy | **Advisory only** — console.warn on missing/expired/tampered/mismatched. No nag UI. No render-blocking. Same as today. | +| `LicenseClaims` schema | **Unchanged.** `{ sub, tier, iat, exp, seats }`. Origin allowlist deferred to a future PR. | +| Token source at runtime | **`ChatConfig.license` only.** No env-var fallback. Documented convention; buyer pastes the token into their app config. | +| Public key in published bundle | **Wire existing GH secret `CACHEPLANE_LICENSE_PUBLIC_KEY` into `publish.yml`** so the prebuild script bakes it in. | +| Private signing key | **Already on Vercel project `threadplane-minting-service`** as `LICENSE_SIGNING_PRIVATE_KEY_HEX`. Confirmed via Vercel API. | +| Minting service deploy | **Add to `.github/workflows/ci.yml`** (parallel to website/cockpit deploys). Needs new GH secret `VERCEL_MINTING_PROJECT_ID = prj_3x6SBua2bmAk374uFrp0MdqZSe9u`. | +| Prod domain for minting | **`minting.threadplane.ai`** assigned in Vercel UI (one-time manual). | +| `runLicenseCheck` idempotency bug | **Fix.** Currently second call with no token short-circuits to `'licensed'`. | +| Smoke testing | **Use `examples/chat/angular/` (local `demo.threadplane.ai` source)** with temporary license-string injection. **All injections reverted before commit; demo stays unlicensed in main.** | + +## Out of scope + +- Pricing page changes (PR B already landed). +- Stripe Checkout wiring (PR B-Stripe). +- Origin allowlist / appId / claims schema changes. +- Nag UI / banner component / license-state signal exposed to consumers. +- Token revocation, renewal, or grace-period UX. +- Rotating the dev fixture public key. +- Minting service code changes beyond what's necessary to make CI deploy succeed. + +## File map + +- **Modify:** `.github/workflows/publish.yml` — add `env: CACHEPLANE_LICENSE_PUBLIC_KEY` to the build step that runs `nx build licensing` so `generate-public-key.mjs` picks up the prod key. +- **Modify:** `.github/workflows/ci.yml` — add a `minting-deploy` job (parallel to existing website/cockpit deploys) gated on `github.ref == 'refs/heads/main'`. Uses `VERCEL_TOKEN`, `VERCEL_ORG_ID`, and the new `VERCEL_MINTING_PROJECT_ID` secret. +- **Add GH secret (operational, not code):** `VERCEL_MINTING_PROJECT_ID = prj_3x6SBua2bmAk374uFrp0MdqZSe9u`. +- **Vercel UI (operational, not code):** assign `minting.threadplane.ai` to the `threadplane-minting-service` project; add `STRIPE_WEBHOOK_SECRET` env var (production + preview). The other 19 env vars are already set. +- **Modify:** `libs/licensing/src/lib/run-license-check.ts` — fix the idempotency bug. On a second call with the same `(packageName, token, publicKey)` triple, return the *cached actual status*, not the literal `'licensed'`. +- **Add:** `libs/licensing/src/lib/run-license-check.spec.ts` (or extend existing spec) — regression test for the idempotency fix. +- **Modify:** `libs/chat/README.md` — add a short "Using a commercial license" section showing the `provideChat({ license: '...' })` snippet and pointing to the pricing page for tokens. +- **Modify:** *one example app's* `app.config.ts` to read a license string from a Vite build-time define and pass it to `provideChat({ license })`. Likely `examples/chat/angular/` since that's already where smoke-testing happens. The token is set via env (`NGAF_LICENSE_TOKEN`) at build time; if unset, `license: undefined` is passed and behavior is identical to today. This means the verify path is *runnable* in CI smoke when we choose to set the env, but the example doesn't *require* a license to run. + +No changes to `libs/chat` source code, `@ngaf/render`, `@ngaf/agent`, `@ngaf/langgraph`, `@ngaf/ag-ui`, `@ngaf/a2ui`, `@ngaf/telemetry`, `@ngaf/design-tokens`, the website's pricing page, or any cockpit demo. + +## The idempotency bug + +Investigation found (`libs/licensing/src/lib/run-license-check.ts` — current behavior): + +> The idempotency guard has a bug: if called the second time with no token, it short-circuits and returns `'licensed'` regardless. + +The cause: the dedup `Set` keyed by `package|status` only fires the warn once, but the function's *return value* falls through to `'licensed'` on the cached-path branch. The fix is to cache the computed `LicenseStatus` on the dedup record itself and return *that*, not the constant `'licensed'`. + +Test must assert: with no token, a single call returns `'missing'` (or `'noncommercial'` per `inferNoncommercial`) — and a second call with the same args returns the *same value*, not `'licensed'`. + +## CI workflow changes + +### `publish.yml` — public-key injection + +Find the step that runs `nx build licensing` (or `nx run-many ... build ... licensing`) and add: + +```yaml + env: + CACHEPLANE_LICENSE_PUBLIC_KEY: ${{ secrets.CACHEPLANE_LICENSE_PUBLIC_KEY }} +``` + +The existing `libs/licensing/scripts/generate-public-key.mjs` already reads this env var; no script change needed. + +### `ci.yml` — minting deploy job + +Add a new job mirroring the website's deploy job pattern (whichever pattern that uses — likely `vercel pull` + `vercel build` + `vercel deploy --prebuilt --prod`). The deploy job: + +- Triggers only on push to `main` (not on PR builds). +- Uses `VERCEL_TOKEN`, `VERCEL_ORG_ID`, and the new `VERCEL_MINTING_PROJECT_ID`. +- Targets `apps/minting-service/`. +- Runs after the `library` and `cockpit` jobs pass (`needs:`). + +The minting service's `vercel.json` already has `"rootDirectory": "apps/minting-service"` and Vercel knows what to do; we just need the workflow to invoke `vercel deploy` against the right project. + +## End-to-end smoke test runbook (post-merge, operational, no code committed) + +This is the combined PR-B-Stripe + PR-C smoke. **Every temporary code change made during this runbook is reverted before the session ends.** The chat demo stays unlicensed in main. + +### Prep + +- Stripe test mode enabled; `STRIPE_SECRET_KEY=sk_test_...` set in local `apps/website/.env.local`. +- Minting service deployed and reachable at its preview URL (or `minting.threadplane.ai` if domain is live). +- Stripe Dashboard webhook → minting service `/api/stripe-webhook` configured for test mode. +- Local terminal in worktree root. + +### Step 1 — Full Stripe → mint → email loop + +- [ ] `node scripts/stripe/sync-products.ts` (test mode). Confirm 3 prices created/updated. +- [ ] Boot `apps/website` dev server. Open `/pricing` in Chrome MCP (`mcp__Claude_Preview__preview_start` → `mcp__Claude_in_Chrome__navigate`). +- [ ] Click "Buy indie license" → complete Checkout with `4242 4242 4242 4242`. +- [ ] Wait ~30 seconds. Check the test email inbox (`EMAIL_FROM` recipient) for a license-token email. Copy the token. +- [ ] (If using `stripe trigger` CLI: optionally trigger `customer.subscription.deleted` for revocation behavior — but with one-time payments, this is N/A. Skip.) + +### Step 2 — Verify the token in the chat demo (temporary, reverted) + +- [ ] Open `examples/chat/angular/src/app/app.config.ts`. Find the `provideChat({...})` call. +- [ ] **Temporarily** add `license: ''` to the config. Note the file in your git status so you remember to revert. +- [ ] `npx nx serve examples-chat-angular`. Open `http://localhost:4400` in Chrome MCP. +- [ ] Inspect the browser console. With a valid license: **no warnings**. With no license: console.warn line about advisory mode. +- [ ] **Revert.** `git checkout -- examples/chat/angular/src/app/app.config.ts`. Confirm `git diff` is empty for that file before committing anything else. + +### Step 3 — Verify error states (each one temporary, reverted) + +For each state, edit the demo `app.config.ts`, observe console, revert. + +- [ ] **Tampered:** chop one character off the token. Expected console: warn with `status: 'tampered'`. +- [ ] **Expired:** mint a token via the minting service with `exp` in the past (use a Node REPL + `signLicense()` from `@ngaf/licensing`). Expected console: warn with `status: 'expired'`. **Within the 14-day grace window:** `status: 'grace'`. +- [ ] **Wrong key:** use a token signed with a different keypair (e.g., test keypair from `libs/licensing/src/lib/testing/keypair.ts`). Expected console: warn with `status: 'tampered'` (verify fails before claim parsing). +- [ ] **Missing license + noncommercial detection:** remove the license entry entirely. If `NODE_ENV !== 'production'`, expected `status: 'noncommercial'`; in a production build, expected `status: 'missing'`. + +After each test, `git diff examples/chat/angular/` MUST be empty. + +### Step 4 — Confirm published bundle picks up prod key (verify before publish) + +- [ ] On a clean checkout of `main` after this PR lands, run `npx nx build chat`. Read `dist/libs/licensing/fesm2022/*.mjs` (or the equivalent .js) and grep for the public-key hex. Confirm it's the prod hex from the GH secret, **not** the dev fixture `793132582f3d39dcd46cc6fd010c6c4b10f1225132e7de71fbcb45788ea5afde`. +- [ ] (Optional, future) Wire this check into CI as a guard step that fails the publish if the bundled key matches the dev fixture. + +### Step 5 — Final scrub + +- [ ] `git status` in worktree shows no untracked changes in `examples/chat/`, `apps/website/`, `libs/chat/`, or `libs/licensing/`. +- [ ] Smoke test successful → close the loop with a short report in the PR description. + +## Acceptance criteria + +1. `publish.yml` includes `CACHEPLANE_LICENSE_PUBLIC_KEY` in the env for the licensing build step. +2. `ci.yml` includes a `minting-deploy` job that runs only on push to `main` and deploys the `threadplane-minting-service` Vercel project. +3. GH secret `VERCEL_MINTING_PROJECT_ID` is set (operational, not in code; confirm with `gh secret list`). +4. `libs/licensing/src/lib/run-license-check.ts` returns the cached `LicenseStatus`, not the constant `'licensed'`, on repeat calls. Unit test asserts this for the no-token path. +5. `libs/chat/README.md` has a "Using a commercial license" section with a `provideChat({ license: '...' })` snippet. +6. `examples/chat/angular/src/app/app.config.ts` reads `NGAF_LICENSE_TOKEN` from a build-time define and passes it through `provideChat({ license })`. When unset, the demo behaves identically to today. +7. `npx nx run-many -t lint,test,build --projects=licensing,chat` passes. +8. Smoke test runbook (above) was executed once and reported in the PR description with screenshots/console captures. **All temporary code changes are reverted; `git status` is clean on the demo files at PR open.** + +## Risks + +- **Public-key/private-key mismatch.** The GH secret was set 2026-04-30; the Vercel private key was set in roughly the same window. Likely match, but unverifiable from secrets alone. Mitigation: do an explicit derive-from-private check during the smoke test runbook (Vercel CLI `env pull` → derive public via `@noble/ed25519` → compare to GH secret value pasted in by hand). +- **Demo pollution.** The smoke test runbook explicitly calls out revert-before-commit on every step. The minting flow itself doesn't touch the demo; only manual config edits do. +- **Minting service unhealthy after deploy.** If env vars are missing or the Resend key has expired, tokens won't be emailed. Mitigation: the existing `/api/health` endpoint should be hit immediately after first deploy. +- **publish.yml change is silent.** If `CACHEPLANE_LICENSE_PUBLIC_KEY` happens to be empty (secret accidentally cleared), the prebuild falls back to the dev fixture silently. Optional follow-up: add the CI guard mentioned in smoke step 4. +- **No example wiring change visible to demo users.** Setting `NGAF_LICENSE_TOKEN` at build time is a CI/maintainer concern; demo viewers won't see any behavior change. diff --git a/docs/superpowers/specs/2026-05-20-stripe-checkout-design.md b/docs/superpowers/specs/2026-05-20-stripe-checkout-design.md new file mode 100644 index 000000000..420449ad4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-stripe-checkout-design.md @@ -0,0 +1,128 @@ +# PR B-Stripe — Stripe Checkout for Paid Tiers + +**Status:** Design approved, ready for implementation plan. +**Owner:** apps/website + apps/minting-service (the webhook handler that already exists there) +**Affects:** `apps/website/` (pricing page CTAs + new Checkout API route + thanks page + new `tiers.config.ts`), `scripts/stripe/` (new idempotent products/prices sync script), env config (Stripe keys), Stripe Dashboard webhook config. + +## Goal + +Replace the placeholder `/contact?source=pricing_tier_` CTAs that PR B shipped with real Stripe-hosted Checkout. A buyer who clicks "Buy indie license" lands on `stripe.com/c/pay/...`, completes payment, and is redirected to a thank-you page that tells them their license token will arrive via email (sent by the already-existing minting service in `apps/minting-service/`). + +This PR does **not** mint or deliver license tokens. The minting service's `apps/minting-service/handlers/stripe-webhook.ts` already does that — but it's not yet deployed to a live URL. The deployment + production wiring happens in PR C. **A Stripe Checkout completion can succeed in PR B-Stripe with no token delivery; that gap closes when PR C lands.** That ordering is deliberate: sales can begin (manual fulfillment) before runtime verification is fully wired. + +## Out of scope + +- License-token minting / Resend emails — done by the existing minting service; PR C deploys it. +- Webhook signature verification logic — already in `apps/minting-service/handlers/stripe-webhook.ts`. +- Token signing keys (handled in PR C). +- Subscriptions / renewals. Per the brainstorm, **all paid tiers are one-time 12-month payments.** Renewal nag at exp-30d is a follow-up PR. +- Pricing copy / tier definitions — locked by PR B; this PR reads them. +- Origin-allowlist field collection at checkout — deferred (see PR C spec). + +## Decisions locked from brainstorm + +| Topic | Decision | +|---|---| +| Checkout flow shape | **Stripe-hosted Checkout** (POST to `/api/checkout/session` → 303 redirect to Stripe URL → return to `/thanks?session_id=...`) | +| Billing model | **One-time payments**, all paid tiers. License token TTL = 365 days, set by minting service. | +| Webhook → token minting | Out of scope here; the existing webhook handler in `apps/minting-service/` already covers it. | +| Products / prices config | **Idempotent script** `scripts/stripe/sync-products.ts` reading `pricing/tiers.config.ts` as source of truth. | +| Test vs. live | Ship with test mode keys; live mode is enabled by setting the production secret in Vercel after smoke-testing. | + +## Pricing source of truth + +New file: `pricing/tiers.config.ts` (repo root, importable by both website and sync script). + +```ts +// pricing/tiers.config.ts +export interface TierConfig { + /** Stable identifier; used in URL params, CtaIds, Stripe product metadata. */ + readonly slug: 'community' | 'indie' | 'developer_seat' | 'app_deployment' | 'enterprise'; + /** Display name on /pricing. */ + readonly name: string; + /** USD price in cents. null for free / custom. */ + readonly priceCents: number | null; + /** Display "$149" — derived from priceCents for paid tiers, "Free"/"Custom" otherwise. */ + readonly displayPrice: string; + /** Display "/year" or "/developer/year" etc. */ + readonly displayPeriod: string; + /** Pricing-grid feature bullets. */ + readonly features: readonly string[]; + /** Should the tier go through Stripe? false → community (npm) + enterprise (sales). */ + readonly stripeBuyable: boolean; + /** Whether the line item allows quantity adjustment (Developer Seat). */ + readonly adjustableQuantity?: boolean; +} + +export const TIERS: readonly TierConfig[] = [ /* 5 entries matching PR B's PricingGrid */ ]; +``` + +The script reads this file as the source of truth, creates/updates one Stripe product per `stripeBuyable` tier, one price per product, and writes the resulting Stripe IDs to `pricing/tiers.generated.ts` (committed; small, human-auditable). + +```ts +// pricing/tiers.generated.ts (script output, committed) +export const STRIPE_PRICE_IDS: Record<'indie' | 'developer_seat' | 'app_deployment', string> = { + indie: 'price_1Abc...', + developer_seat: 'price_1Def...', + app_deployment: 'price_1Ghi...', +}; +``` + +**Why two files:** the config is intent (human-editable); the generated file is the contract between Stripe Dashboard state and our app. Separating them means accidental Stripe-side changes don't silently break us; the script is idempotent so re-running rebuilds the generated file from current Stripe state. + +## File map + +- **Create:** `pricing/tiers.config.ts` — source of truth. +- **Create:** `pricing/tiers.generated.ts` — script output; Stripe price IDs. +- **Create:** `scripts/stripe/sync-products.ts` — idempotent product/price sync via `stripe.products.list` / `stripe.products.create` / `stripe.products.update`, then `stripe.prices.list` / `stripe.prices.create`. Identifies products by metadata key `ngaf_tier_slug`. +- **Create:** `apps/website/src/app/api/checkout/session/route.ts` — Next.js Route Handler. POST `{ tier: 'indie' | 'developer_seat' | 'app_deployment', quantity?: number }` → `303 Location: `. Builds the session with `mode: 'payment'`, `line_items: [{ price: STRIPE_PRICE_IDS[tier], quantity, adjustable_quantity: tier === 'developer_seat' ? { enabled: true, minimum: 1, maximum: 100 } : undefined }]`, `success_url: '/thanks?session_id={CHECKOUT_SESSION_ID}'`, `cancel_url: '/pricing'`, `metadata: { ngaf_tier_slug: tier }`, `payment_intent_data: { metadata: { ngaf_tier_slug: tier } }`. +- **Create:** `apps/website/src/app/thanks/page.tsx` — server component that renders "Payment received. Your license token will be emailed within a few minutes." Reads `?session_id=` for analytics; uses `Stripe-hosted` posture so we don't need to call back to Stripe for confirmation (the webhook handles fulfillment). +- **Modify:** `apps/website/src/components/pricing/PricingGrid.tsx` — paid-tier CTAs replace `href="/contact?source=..."` with a small form `
` so the click POSTs and gets a 303. Click still fires `trackCtaClick`. Community keeps the npm link; Enterprise keeps the `/contact` link. +- **Modify:** `apps/website/src/lib/analytics/events.ts` — extend `EventType` with `marketing:checkout_started` and `marketing:checkout_succeeded` (or reuse existing CTA events; final decision in plan). +- **Modify:** `.github/workflows/ci.yml` — no change yet. Stripe is server-side; sync script runs locally. +- **Modify:** `.env.example` (repo root + `apps/website/.env.example`) — document `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` env names (no values). + +No changes to `libs/chat`, `libs/licensing`, `apps/minting-service`, the cockpit, or any example app. All other libraries stay untouched. + +## Stripe configuration (operational, done before merge) + +These actions happen in the Stripe Dashboard and on Vercel — they aren't code: + +1. **Run `node scripts/stripe/sync-products.ts` locally with `STRIPE_SECRET_KEY` (test mode).** Creates the three buyable products + prices in Stripe test mode. Writes `pricing/tiers.generated.ts`. The script is idempotent so reruns are safe. +2. **In Stripe Dashboard, point the webhook endpoint at the minting service URL.** Test mode: `https://threadplane-minting-service.vercel.app/api/stripe-webhook` (current preview-style URL; gets a custom prod domain in PR C). Production: same path on the prod minting domain. Webhook signing secret goes into the minting service's `STRIPE_WEBHOOK_SECRET` Vercel env (test mode value first; live mode value when ready). +3. **Vercel `threadplane` (website) project gets `STRIPE_SECRET_KEY`** (test mode now). This is the only Stripe secret the website needs; the public/test publishable key isn't required because we don't use Elements. + +## Acceptance criteria + +1. `node scripts/stripe/sync-products.ts` exits 0 and writes a `pricing/tiers.generated.ts` containing 3 `price_*` strings. Rerunning is a no-op. +2. POST `/api/checkout/session` with `{ tier: 'indie' }` returns `303 Location: https://checkout.stripe.com/c/pay/cs_test_...`. +3. POST `/api/checkout/session` with `{ tier: 'developer_seat', quantity: 3 }` produces a Checkout session with `quantity=3` and `adjustable_quantity` enabled. +4. POST `/api/checkout/session` with `{ tier: 'enterprise' }` or `{ tier: 'community' }` returns `400` (those tiers don't go through Stripe). +5. Completing test-mode Checkout with card `4242 4242 4242 4242` redirects to `/thanks?session_id=cs_test_...` and renders the thank-you page. +6. The Stripe Dashboard shows the resulting `payment_intent` with `metadata.ngaf_tier_slug` set correctly. +7. The minting service webhook receives a `checkout.session.completed` event (verify via the minting service's Vercel logs). +8. The pricing page UI looks unchanged except the paid-tier buttons now submit a form instead of navigating; analytics events still fire. +9. `npx nx run website:lint` + `npx nx run website:test` + `npx nx build website` all succeed. + +## Verification / smoke test runbook (operational, not committed) + +Run after merge with Stripe in test mode. **All temporary changes get reverted before the smoke-test session ends.** See PR C's spec for the consolidated end-to-end smoke that combines Stripe Checkout + license-token issuance + verification in the chat demo. + +PR-B-Stripe-specific smoke: + +- [ ] Run `node scripts/stripe/sync-products.ts` locally. Confirm 3 prices appear in the Stripe Dashboard test mode. +- [ ] Boot `apps/website` dev server. Open `http://localhost:3000/pricing` in a real browser (Chrome MCP). +- [ ] Click "Buy indie license". Confirm browser lands on `checkout.stripe.com` with the right price + product name. +- [ ] Pay with `4242 4242 4242 4242` + any future expiry + any 3-digit CVC. +- [ ] Confirm redirect to `/thanks?session_id=cs_test_...` and "Your license token will be emailed" copy renders. +- [ ] Repeat for Developer Seat — confirm the quantity stepper works and the price scales. +- [ ] Repeat for App Deployment. +- [ ] Stripe Dashboard → Events → confirm `checkout.session.completed` was sent to the minting webhook URL (the deployed preview URL is fine for this PR; live domain comes in PR C). +- [ ] Cancel a session mid-flow; confirm we return to `/pricing` cleanly. + +## Risks + +- **Token won't actually arrive in PR B-Stripe.** The minting service receives the webhook and tries to mint; PR C is the one that wires its prod public key into `@ngaf/chat` and assigns the prod domain. The thank-you page text deliberately says "within a few minutes" to leave room for manual fulfillment if the automation isn't end-to-end yet. +- **Idempotency edge cases.** The sync script needs to match products by `metadata.ngaf_tier_slug`, not by name (names change). The plan locks the metadata key explicitly. +- **CSRF on POST /api/checkout/session.** Since the redirect is to Stripe, there's no real damage from a forged POST — worst case a Stripe session URL gets fetched. Acceptable; the route still verifies `tier` is in the allowlist. +- **Test/live mode mixup.** The same `STRIPE_SECRET_KEY` env var holds whichever mode. Mitigation: prefix the test-mode key with `sk_test_` (Stripe's convention); add a runtime check that errors if `STRIPE_SECRET_KEY` doesn't start with `sk_` and refuses to load. We do NOT add explicit mode detection — Stripe's key already encodes it. From 22b66dc500a0d3f1179e5dbde62f39f6ec873931 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 08:53:47 -0700 Subject: [PATCH 02/15] docs(superpowers): plan for Stripe Checkout (PR B-Stripe) 9 tasks: tiers.config source of truth, generated price-IDs stub, Stripe client wrapper with sk_ guard, /api/checkout/session route, /thanks page, PricingGrid form swap for paid tiers, idempotent products/prices sync script, analytics events + env example, final verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-21-stripe-checkout.md | 1292 +++++++++++++++++ 1 file changed, 1292 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-stripe-checkout.md diff --git a/docs/superpowers/plans/2026-05-21-stripe-checkout.md b/docs/superpowers/plans/2026-05-21-stripe-checkout.md new file mode 100644 index 000000000..46075e7d6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-stripe-checkout.md @@ -0,0 +1,1292 @@ +# Stripe Checkout (PR B-Stripe) 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:** Replace the placeholder `/contact?source=pricing_tier_` CTAs on `/pricing` with real Stripe-hosted Checkout for the three buyable tiers (Indie, Developer Seat, App Deployment). One-time 12-month payments; no subscriptions. Token minting is handled by the existing `apps/minting-service/` webhook handler — out of scope here. + +**Architecture:** A single source-of-truth config file (`pricing/tiers.config.ts`) drives both the website's PricingGrid and an idempotent Node script (`scripts/stripe/sync-products.ts`) that creates/updates Stripe products + prices and writes their IDs to `pricing/tiers.generated.ts`. A new Next.js Route Handler at `/api/checkout/session` accepts `POST { tier, quantity? }`, calls `stripe.checkout.sessions.create`, and 303-redirects to the hosted Checkout URL. Success returns to a new `/thanks` page; cancel returns to `/pricing`. The pricing grid swaps its ` + + + + + + ); +} +``` + +- [ ] **Step 2: Write spec** + +Create `apps/website/src/app/thanks/page.spec.tsx`: + +```tsx +// SPDX-License-Identifier: MIT +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import ThanksPage from './page'; + +vi.mock('../../components/ui/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../../components/ui/Section', () => ({ + Section: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../../components/ui/Eyebrow', () => ({ + Eyebrow: ({ children }: { children: React.ReactNode }) => {children}, +})); +vi.mock('../../components/ui/Button', () => ({ + Button: ({ children, href }: { children: React.ReactNode; href?: string }) => + {children}, +})); + +describe('ThanksPage', () => { + it('renders the payment-received heading', () => { + render(); + expect(screen.getByRole('heading', { level: 1, name: 'Thanks for your purchase.' })).toBeTruthy(); + }); + + it('mentions provideChat() activation', () => { + render(); + expect(screen.getByText(/provideChat\(\)/)).toBeTruthy(); + }); + + it('links to installation docs and contact', () => { + render(); + expect(screen.getByRole('link', { name: 'Installation docs' }).getAttribute('href')) + .toBe('/docs/chat/getting-started/installation'); + expect(screen.getByRole('link', { name: 'Contact support' }).getAttribute('href')) + .toBe('/contact'); + }); +}); +``` + +- [ ] **Step 3: Run spec** + +From `apps/website/`: `npx vitest run src/app/thanks/page.spec.tsx 2>&1 | tail -8` +Expected: 3 passed. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/app/thanks/page.tsx apps/website/src/app/thanks/page.spec.tsx +git commit -m "$(cat <<'EOF' +feat(website): add /thanks page for Checkout success returns + +Tells buyers their @ngaf/chat license token will be emailed within a +few minutes, links to installation docs and support. Static server +component; reads no query params at runtime — Stripe handles the +session_id reconciliation via webhook in the minting service. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Refactor PricingGrid to read from tiers.config + POST form for paid tiers + +**Files:** +- Modify: `apps/website/src/components/pricing/PricingGrid.tsx` + +- [ ] **Step 1: Read current state** + +Run: `cat apps/website/src/components/pricing/PricingGrid.tsx | head -120` +Confirm the file currently has an inline `PLANS` array with 5 entries and uses ` + + ) : ( + + )} + + ); + })} + + + + ); +} +``` + +- [ ] **Step 3: Type-check + lint** + +From repo root: +``` +npx tsc -p apps/website/tsconfig.json --noEmit 2>&1 | grep -i PricingGrid | grep -v TS6305 || echo "ok" +npx nx run website:lint 2>&1 | grep -iE "PricingGrid|error " || echo "ok" +``` +Both expect `ok`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/components/pricing/PricingGrid.tsx +git commit -m "$(cat <<'EOF' +feat(website): pricing grid posts to Stripe Checkout for paid tiers + +- Tier data now sourced from pricing/tiers.config.ts (single source of + truth shared with the Stripe sync script). +- Paid tiers (Indie / Developer Seat / App Deployment) submit a POST + form to /api/checkout/session which 303-redirects to Stripe-hosted + Checkout. +- Community keeps the npm link; Enterprise keeps /contact. +- Tracking events unchanged. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Create `scripts/stripe/sync-products.ts` idempotent sync + +**Files:** +- Create: `scripts/stripe/sync-products.ts` +- Create: `scripts/stripe/sync-products.spec.ts` + +- [ ] **Step 1: Write the script** + +Create `scripts/stripe/sync-products.ts`: + +```ts +// SPDX-License-Identifier: MIT +/** + * Idempotent Stripe products + prices sync. + * + * Reads pricing/tiers.config.ts and ensures each `stripeBuyable: true` tier + * has a Stripe product (matched by metadata.ngaf_tier_slug) and exactly one + * active one-time price. Writes the resulting price IDs to + * pricing/tiers.generated.ts. + * + * Usage: + * STRIPE_SECRET_KEY=sk_test_... pnpm tsx scripts/stripe/sync-products.ts + * + * Re-running is safe: products are matched by metadata, prices are reused if + * the unit_amount matches, otherwise the old price is archived and a new one + * created. + */ +import Stripe from 'stripe'; +import fs from 'node:fs'; +import path from 'node:path'; +import { BUYABLE_TIERS, type TierConfig } from '../../pricing/tiers.config'; + +const METADATA_KEY = 'ngaf_tier_slug'; + +async function findOrCreateProduct(stripe: Stripe, tier: TierConfig): Promise { + // Stripe doesn't support metadata search on products in the standard list API + // (it would need /v1/products/search), so we paginate and filter. + const search = await stripe.products.search({ + query: `metadata['${METADATA_KEY}']:'${tier.slug}'`, + limit: 1, + }); + const existing = search.data[0]; + if (existing) { + if (existing.name !== tier.name || existing.active === false) { + return stripe.products.update(existing.id, { name: tier.name, active: true }); + } + return existing; + } + return stripe.products.create({ + name: tier.name, + metadata: { [METADATA_KEY]: tier.slug }, + }); +} + +async function findOrCreatePrice( + stripe: Stripe, + product: Stripe.Product, + tier: TierConfig, +): Promise { + if (tier.priceCents === null) { + throw new Error(`Tier ${tier.slug} has null priceCents but is marked stripeBuyable`); + } + const prices = await stripe.prices.list({ product: product.id, active: true, limit: 10 }); + const match = prices.data.find( + (p) => p.unit_amount === tier.priceCents && p.currency === 'usd' && p.type === 'one_time', + ); + if (match) return match; + + // Archive any active prices that don't match (one active price per tier). + for (const stale of prices.data) { + await stripe.prices.update(stale.id, { active: false }); + } + + return stripe.prices.create({ + product: product.id, + currency: 'usd', + unit_amount: tier.priceCents, + metadata: { [METADATA_KEY]: tier.slug }, + }); +} + +function renderGeneratedFile(idsBySlug: Record): string { + const entries = Object.entries(idsBySlug) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)},`) + .join('\n'); + return `// SPDX-License-Identifier: MIT +// Generated by scripts/stripe/sync-products.ts. Do not edit by hand. +import type { TierSlug } from './tiers.config'; + +export const STRIPE_PRICE_IDS: Partial, string>> = { +${entries} +}; +`; +} + +export async function syncProducts(stripe: Stripe): Promise> { + const idsBySlug: Record = {}; + for (const tier of BUYABLE_TIERS) { + const product = await findOrCreateProduct(stripe, tier); + const price = await findOrCreatePrice(stripe, product, tier); + idsBySlug[tier.slug] = price.id; + // eslint-disable-next-line no-console + console.log(`✓ ${tier.slug}: product=${product.id} price=${price.id} (${tier.priceCents}¢)`); + } + return idsBySlug; +} + +async function main(): Promise { + const key = process.env['STRIPE_SECRET_KEY']; + if (!key || !key.startsWith('sk_')) { + throw new Error('STRIPE_SECRET_KEY must be set and begin with sk_'); + } + const stripe = new Stripe(key, { apiVersion: '2025-09-30.clover' }); + const ids = await syncProducts(stripe); + const outPath = path.join(process.cwd(), 'pricing', 'tiers.generated.ts'); + fs.writeFileSync(outPath, renderGeneratedFile(ids)); + // eslint-disable-next-line no-console + console.log(`\nWrote ${outPath}`); +} + +if (require.main === module) { + main().catch((err: unknown) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); + }); +} +``` + +- [ ] **Step 2: Write spec** + +Create `scripts/stripe/sync-products.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi } from 'vitest'; +import type Stripe from 'stripe'; +import { syncProducts } from './sync-products'; + +function stubStripe(opts: { + productSearch?: Stripe.Product[]; + priceList?: Stripe.Price[]; +} = {}): Stripe { + const products = { + search: vi.fn().mockResolvedValue({ data: opts.productSearch ?? [] }), + create: vi.fn().mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: `prod_new_${name.replace(/\W+/g, '_')}`, name, active: true })), + update: vi.fn().mockImplementation((id: string, body: Stripe.ProductUpdateParams) => + Promise.resolve({ id, ...body, active: true })), + }; + const prices = { + list: vi.fn().mockResolvedValue({ data: opts.priceList ?? [] }), + create: vi.fn().mockImplementation((body: Stripe.PriceCreateParams) => + Promise.resolve({ id: `price_new_${body.unit_amount}`, ...body })), + update: vi.fn().mockImplementation((id: string) => Promise.resolve({ id, active: false })), + }; + return { products, prices } as unknown as Stripe; +} + +describe('syncProducts', () => { + it('creates a new product and price when none exist', async () => { + const stripe = stubStripe(); + const ids = await syncProducts(stripe); + expect(Object.keys(ids).sort()).toEqual(['app_deployment', 'developer_seat', 'indie']); + expect(ids.indie.startsWith('price_new_14900')).toBe(true); + }); + + it('reuses an existing product and matching active price', async () => { + const existingIndieProduct = { + id: 'prod_existing_indie', + name: 'Indie Commercial', + active: true, + } as Stripe.Product; + const existingIndiePrice = { + id: 'price_existing_indie', + product: 'prod_existing_indie', + unit_amount: 14900, + currency: 'usd', + type: 'one_time', + active: true, + } as Stripe.Price; + const stripe = stubStripe({ + productSearch: [existingIndieProduct], + priceList: [existingIndiePrice], + }); + const ids = await syncProducts(stripe); + expect(ids.indie).toBe('price_existing_indie'); + }); + + it('archives a stale price when unit_amount no longer matches and creates a new one', async () => { + const staleIndiePrice = { + id: 'price_stale_indie', + product: 'prod_existing_indie', + unit_amount: 9900, + currency: 'usd', + type: 'one_time', + active: true, + } as Stripe.Price; + const existingIndieProduct = { + id: 'prod_existing_indie', + name: 'Indie Commercial', + active: true, + } as Stripe.Product; + const stripe = stubStripe({ + productSearch: [existingIndieProduct], + priceList: [staleIndiePrice], + }); + const ids = await syncProducts(stripe); + expect(ids.indie.startsWith('price_new_14900')).toBe(true); + // Archive call was made + // @ts-expect-error vitest mock typing + expect(stripe.prices.update).toHaveBeenCalledWith('price_stale_indie', { active: false }); + }); +}); +``` + +- [ ] **Step 3: Run the spec** + +Run: `npx vitest run scripts/stripe/sync-products.spec.ts 2>&1 | tail -10` +Expected: 3 passed. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/stripe/sync-products.ts scripts/stripe/sync-products.spec.ts +git commit -m "$(cat <<'EOF' +feat(stripe): idempotent products + prices sync script + +Reads pricing/tiers.config.ts and ensures each stripeBuyable tier has a +Stripe product (matched by metadata.ngaf_tier_slug) and exactly one +active one-time USD price. Writes pricing/tiers.generated.ts with the +resulting price IDs. Re-runnable; stale prices are archived when +unit_amount changes. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Wire up new analytics events + env example + +**Files:** +- Modify: `apps/website/src/lib/analytics/events.ts` +- Modify: `apps/website/.env.example` (create if absent) +- Modify: `.env.example` (root) + +- [ ] **Step 1: Add checkout event types** + +Find the `EventType` union in `apps/website/src/lib/analytics/events.ts`. (If no such union exists, the codebase uses a different shape — read the file first.) + +Add two new literal members: + +``` + | 'marketing:checkout_started' + | 'marketing:checkout_succeeded' +``` + +If the file uses an enum or constants object instead of a union, add the corresponding entries. The existing PR-B pricing CTAs use `trackCtaClick` only; these new events are *additional* signals (started: server-side could log in the route; succeeded: client-side on the /thanks page if we add tracking later). + +- [ ] **Step 2: Document env vars** + +Create or extend `apps/website/.env.example`: + +```env +# Stripe — see scripts/stripe/sync-products.ts and src/app/api/checkout/session/route.ts +STRIPE_SECRET_KEY=sk_test_… +``` + +Extend root `.env.example` similarly (append, do not overwrite existing lines). + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/lib/analytics/events.ts apps/website/.env.example .env.example +git commit -m "$(cat <<'EOF' +chore(website): add checkout analytics events + Stripe env example + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Final verification + +**Files:** none (verification only). + +- [ ] **Step 1: Full test suite** + +From `apps/website/`: `npx vitest run 2>&1 | tail -8` +From repo root: `npx vitest run scripts/stripe/sync-products.spec.ts 2>&1 | tail -8` +Expected: all green; PR-B baseline + the new specs from Tasks 3, 4, 5, 7. + +- [ ] **Step 2: Lint** + +From repo root: `npx nx run website:lint 2>&1 | tail -5` +Expected: `Successfully ran target lint for project website`. + +- [ ] **Step 3: Build** + +From repo root: `npx nx build website 2>&1 | tail -10` +Expected: `Successfully ran target build for project website`. The build must succeed even though `pricing/tiers.generated.ts` is empty — the route returns 503 at runtime, not at build time. + +- [ ] **Step 4: Scope check** + +```bash +git diff --name-only origin/main..HEAD | grep -vE '^(apps/website/|pricing/|scripts/stripe/|docs/superpowers/|\.env\.example)' | head +``` +Expected: empty. + +- [ ] **Step 5: Dry-run the sync script (optional, requires STRIPE_SECRET_KEY)** + +If you have a Stripe test-mode secret available: +``` +STRIPE_SECRET_KEY=sk_test_... pnpm tsx scripts/stripe/sync-products.ts +``` +Expected: three `✓` lines, then `Wrote pricing/tiers.generated.ts`. **Do not commit the generated file's populated form yet** — the full Stripe setup happens during the operational smoke test after PR merges. Revert with `git checkout -- pricing/tiers.generated.ts` before opening the PR. + +--- + +## Self-review + +**Spec coverage:** +- Spec § "Pricing source of truth" → Task 1. ✓ +- Spec § "Stripe price IDs file" → Task 2 + Task 7 Step 1 (`renderGeneratedFile`). ✓ +- Spec § `lib/stripe.ts` + `sk_` guard → Task 3. ✓ +- Spec § `/api/checkout/session` route → Task 4. ✓ +- Spec § `/thanks` page → Task 5. ✓ +- Spec § PricingGrid form swap → Task 6. ✓ +- Spec § `scripts/stripe/sync-products.ts` idempotent sync → Task 7. ✓ +- Spec § new analytics event names → Task 8 Step 1. ✓ +- Spec § env example → Task 8 Step 2. ✓ +- Spec acceptance criteria 1–9 → Task 9. ✓ +- Spec § Smoke test runbook → not in the plan; that's operational (post-merge), runs from the spec. + +**Placeholder scan:** No TBD/TODO. Every code block is fully written. The tsconfig `paths` alias fallback in Task 4 Step 1 is a documented conditional ("if Next.js's compiler dislikes that import depth"); the relative path is the primary approach. + +**Type consistency:** `TierSlug`, `TierConfig`, `BUYABLE_TIERS`, `STRIPE_PRICE_IDS` are consistent across Task 1, Task 2, Task 4, Task 6, Task 7. `CtaId` literals (`pricing_tier_*`) already exist in `events.ts` from PR B. `getStripe()` is consistent across Task 3 (definition), Task 4 (usage), Task 7 (script uses `new Stripe` directly with the same `apiVersion`). + +Plan complete. From eb20feb18d791762a402b0d9e44e61738bb15bb1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:04:59 -0700 Subject: [PATCH 03/15] feat(pricing): add tiers.config.ts source of truth Five-tier definition consumed by both the website's PricingGrid and the Stripe products/prices sync script. Stripe products are matched by metadata.ngaf_tier_slug, never by display name. Co-Authored-By: Claude Opus 4.7 (1M context) --- pricing/tiers.config.ts | 127 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 pricing/tiers.config.ts diff --git a/pricing/tiers.config.ts b/pricing/tiers.config.ts new file mode 100644 index 000000000..5d9055a0b --- /dev/null +++ b/pricing/tiers.config.ts @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +/** + * Single source of truth for /pricing tier display and Stripe product sync. + * Read by: + * - apps/website/src/components/pricing/PricingGrid.tsx (display) + * - scripts/stripe/sync-products.ts (Stripe-side products + prices) + * + * Stripe products are identified by `metadata.ngaf_tier_slug = slug`. Never + * rely on product name to match — names are display copy and may change. + */ +export type TierSlug = + | 'community' + | 'indie' + | 'developer_seat' + | 'app_deployment' + | 'enterprise'; + +export interface TierConfig { + readonly slug: TierSlug; + readonly name: string; + /** USD cents. null for free / custom. */ + readonly priceCents: number | null; + readonly displayPrice: string; + readonly displayPeriod: string; + readonly features: readonly string[]; + /** false → community (npm), enterprise (sales). true → real Stripe product + price. */ + readonly stripeBuyable: boolean; + /** Highlighted card in the PricingGrid. */ + readonly highlight: boolean; + /** Checkout `adjustable_quantity` enabled. Only Developer Seat today. */ + readonly adjustableQuantity?: boolean; + /** Default quantity passed to Stripe Checkout when the buyer doesn't override. */ + readonly defaultQuantity?: number; +} + +export const TIERS: readonly TierConfig[] = [ + { + slug: 'community', + name: 'Community / Noncommercial', + priceCents: null, + displayPrice: 'Free', + displayPeriod: 'forever', + features: [ + 'Personal, student, academic, nonprofit, demo', + 'Source access', + 'Noncommercial use', + 'Commercial evaluation (30 days)', + 'License: PolyForm Noncommercial 1.0.0', + ], + stripeBuyable: false, + highlight: false, + }, + { + slug: 'indie', + name: 'Indie Commercial', + priceCents: 14900, + displayPrice: '$149', + displayPeriod: '/year', + features: [ + '1 developer', + '1 commercial app', + 'Unlimited end users', + 'Commercial license', + 'Best for: solo devs, indie products, consultants with one app', + ], + stripeBuyable: true, + highlight: false, + }, + { + slug: 'developer_seat', + name: 'Developer Seat', + priceCents: 29900, + displayPrice: '$299', + displayPeriod: '/developer/year', + features: [ + 'Commercial use', + 'Unlimited end users', + 'Dev / staging / production', + 'Apps owned by your org', + 'Best for: startups & growing teams', + ], + stripeBuyable: true, + highlight: true, + adjustableQuantity: true, + defaultQuantity: 1, + }, + { + slug: 'app_deployment', + name: 'App Deployment', + priceCents: 149900, + displayPrice: '$1,499', + displayPeriod: '/app/year', + features: [ + 'Unlimited developers', + '1 production app', + 'Unlimited end users', + 'Procurement-friendly', + 'Best for: agencies, CI/CD-heavy teams', + ], + stripeBuyable: true, + highlight: false, + }, + { + slug: 'enterprise', + name: 'Enterprise', + priceCents: null, + displayPrice: 'Custom', + displayPeriod: 'starting at $10k/year', + features: [ + 'Custom contract & SLA', + 'Procurement support', + 'Security review', + 'Multi-app licensing', + 'Priority + private support channel', + ], + stripeBuyable: false, + highlight: false, + }, +]; + +export const BUYABLE_TIERS = TIERS.filter((t) => t.stripeBuyable); + +export function getTier(slug: TierSlug): TierConfig { + const t = TIERS.find((x) => x.slug === slug); + if (!t) throw new Error(`Unknown tier slug: ${slug}`); + return t; +} From c0dac60c4004ea7269393e8228d543e6bf49256b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:05:14 -0700 Subject: [PATCH 04/15] feat(pricing): seed tiers.generated.ts as empty Stripe-IDs map Populated by scripts/stripe/sync-products.ts. The /api/checkout/session route detects an empty map and 503s with "checkout not yet configured." Co-Authored-By: Claude Opus 4.7 (1M context) --- pricing/tiers.generated.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 pricing/tiers.generated.ts diff --git a/pricing/tiers.generated.ts b/pricing/tiers.generated.ts new file mode 100644 index 000000000..bb7faa2cd --- /dev/null +++ b/pricing/tiers.generated.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Generated by scripts/stripe/sync-products.ts. Do not edit by hand. +// Each entry maps a tier slug → Stripe price ID. Empty until the sync +// script has been run against a Stripe account. +import type { TierSlug } from './tiers.config'; + +export const STRIPE_PRICE_IDS: Partial, string>> = { + // Populated by `pnpm tsx scripts/stripe/sync-products.ts`. +}; From 6aadd8d087234013d3b310d22f77c416a6fa3e2a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:07:37 -0700 Subject: [PATCH 05/15] feat(website): add Stripe client wrapper with sk_ guard getStripe() refuses to load if STRIPE_SECRET_KEY is missing or doesn't begin with sk_. The key encodes test vs live mode, so we don't need explicit mode detection elsewhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/package.json | 1 + apps/website/src/lib/stripe.spec.ts | 31 +++++++++++++++++++++++++++++ apps/website/src/lib/stripe.ts | 21 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 apps/website/src/lib/stripe.spec.ts create mode 100644 apps/website/src/lib/stripe.ts diff --git a/apps/website/package.json b/apps/website/package.json index 4cab1de69..86fb078d4 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -14,6 +14,7 @@ "remark-gfm": "^4.0.1", "resend": "^6.10.0", "shiki": "*", + "stripe": "^22.0.2", "tailwind-merge": "^2.5.0" } } diff --git a/apps/website/src/lib/stripe.spec.ts b/apps/website/src/lib/stripe.spec.ts new file mode 100644 index 000000000..ae48c2658 --- /dev/null +++ b/apps/website/src/lib/stripe.spec.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getStripe } from './stripe'; + +describe('getStripe', () => { + const originalKey = process.env['STRIPE_SECRET_KEY']; + + beforeEach(() => { + delete process.env['STRIPE_SECRET_KEY']; + }); + + afterEach(() => { + if (originalKey === undefined) delete process.env['STRIPE_SECRET_KEY']; + else process.env['STRIPE_SECRET_KEY'] = originalKey; + }); + + it('throws when STRIPE_SECRET_KEY is not set', () => { + expect(() => getStripe()).toThrow(/not set/); + }); + + it('throws when STRIPE_SECRET_KEY does not start with sk_', () => { + process.env['STRIPE_SECRET_KEY'] = 'pk_test_garbage'; + expect(() => getStripe()).toThrow(/must begin with "sk_"/); + }); + + it('returns a Stripe client with a valid sk_test_ key', () => { + process.env['STRIPE_SECRET_KEY'] = 'sk_test_1234567890abcdef'; + const stripe = getStripe(); + expect(stripe).toBeTruthy(); + }); +}); diff --git a/apps/website/src/lib/stripe.ts b/apps/website/src/lib/stripe.ts new file mode 100644 index 000000000..1259260a9 --- /dev/null +++ b/apps/website/src/lib/stripe.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +import Stripe from 'stripe'; + +/** + * Returns a configured Stripe client. + * + * Refuses to load if STRIPE_SECRET_KEY is missing or doesn't begin with + * `sk_` (the Stripe convention for secret keys; `sk_test_` for test mode, + * `sk_live_` for live mode). This is the only Stripe environment check we + * do — the key itself encodes test vs live. + */ +export function getStripe(): Stripe { + const key = process.env['STRIPE_SECRET_KEY']; + if (!key) { + throw new Error('STRIPE_SECRET_KEY is not set'); + } + if (!key.startsWith('sk_')) { + throw new Error('STRIPE_SECRET_KEY does not look like a Stripe secret key (must begin with "sk_")'); + } + return new Stripe(key, { apiVersion: '2026-04-22.dahlia' }); +} From 2e67396b73d1a62f2d8a9d5398168f7416ccbe59 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:09:20 -0700 Subject: [PATCH 06/15] feat(website): add /api/checkout/session route handler POST with { tier } returns a 303 redirect to Stripe-hosted Checkout for the three buyable tiers (indie / developer_seat / app_deployment). Community and Enterprise return 400. Quantity is clamped to [1, 100] and adjustable_quantity is enabled only for Developer Seat. Returns 503 when STRIPE_PRICE_IDS is empty (sync-products hasn't run yet). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/api/checkout/session/route.spec.ts | 83 ++++++++++++++++++ .../src/app/api/checkout/session/route.ts | 85 +++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 apps/website/src/app/api/checkout/session/route.spec.ts create mode 100644 apps/website/src/app/api/checkout/session/route.ts diff --git a/apps/website/src/app/api/checkout/session/route.spec.ts b/apps/website/src/app/api/checkout/session/route.spec.ts new file mode 100644 index 000000000..af534c8d2 --- /dev/null +++ b/apps/website/src/app/api/checkout/session/route.spec.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +const stripeCreate = vi.fn(); + +vi.mock('../../../../lib/stripe', () => ({ + getStripe: () => ({ checkout: { sessions: { create: stripeCreate } } }), +})); + +vi.mock('../../../../../../../pricing/tiers.generated', () => ({ + STRIPE_PRICE_IDS: { + indie: 'price_test_indie', + developer_seat: 'price_test_seat', + app_deployment: 'price_test_app', + }, +})); + +import { POST } from './route'; + +function makeReq(body: unknown): NextRequest { + return new NextRequest('http://localhost:3000/api/checkout/session', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/checkout/session', () => { + beforeEach(() => { + stripeCreate.mockReset(); + stripeCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/c/pay/cs_test_abc' }); + }); + + it('returns 400 for unknown tier', async () => { + const res = await POST(makeReq({ tier: 'bogus' })); + expect(res.status).toBe(400); + }); + + it('returns 400 for community tier (not Stripe-buyable)', async () => { + const res = await POST(makeReq({ tier: 'community' })); + expect(res.status).toBe(400); + }); + + it('returns 400 for enterprise tier (not Stripe-buyable)', async () => { + const res = await POST(makeReq({ tier: 'enterprise' })); + expect(res.status).toBe(400); + }); + + it('returns 303 redirect to Stripe for indie', async () => { + const res = await POST(makeReq({ tier: 'indie' })); + expect(res.status).toBe(303); + expect(res.headers.get('location')).toBe('https://checkout.stripe.com/c/pay/cs_test_abc'); + expect(stripeCreate).toHaveBeenCalledTimes(1); + const args = stripeCreate.mock.calls[0]?.[0]; + expect(args.mode).toBe('payment'); + expect(args.line_items[0].price).toBe('price_test_indie'); + expect(args.line_items[0].quantity).toBe(1); + expect(args.metadata.ngaf_tier_slug).toBe('indie'); + }); + + it('enables adjustable_quantity only for developer_seat', async () => { + await POST(makeReq({ tier: 'developer_seat', quantity: 3 })); + const args = stripeCreate.mock.calls[0]?.[0]; + expect(args.line_items[0].quantity).toBe(3); + expect(args.line_items[0].adjustable_quantity).toEqual({ enabled: true, minimum: 1, maximum: 100 }); + }); + + it('clamps quantity to [1, 100]', async () => { + await POST(makeReq({ tier: 'developer_seat', quantity: 9999 })); + expect(stripeCreate.mock.calls[0]?.[0].line_items[0].quantity).toBe(100); + + stripeCreate.mockClear(); + await POST(makeReq({ tier: 'developer_seat', quantity: 0 })); + expect(stripeCreate.mock.calls[0]?.[0].line_items[0].quantity).toBe(1); + }); + + it('returns 502 if Stripe returns no URL', async () => { + stripeCreate.mockResolvedValueOnce({ url: null }); + const res = await POST(makeReq({ tier: 'indie' })); + expect(res.status).toBe(502); + }); +}); diff --git a/apps/website/src/app/api/checkout/session/route.ts b/apps/website/src/app/api/checkout/session/route.ts new file mode 100644 index 000000000..a02574896 --- /dev/null +++ b/apps/website/src/app/api/checkout/session/route.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +import { NextRequest, NextResponse } from 'next/server'; +import { getStripe } from '../../../../lib/stripe'; +import { TIERS, type TierSlug } from '../../../../../../../pricing/tiers.config'; +import { STRIPE_PRICE_IDS } from '../../../../../../../pricing/tiers.generated'; + +const BUYABLE_SLUGS = new Set(['indie', 'developer_seat', 'app_deployment']); + +interface RequestBody { + tier?: string; + quantity?: number; +} + +function getOrigin(req: NextRequest): string { + const forwardedHost = req.headers.get('x-forwarded-host'); + const host = forwardedHost ?? req.headers.get('host') ?? 'localhost:3000'; + const proto = req.headers.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https'); + return `${proto}://${host}`; +} + +export async function POST(req: NextRequest) { + let body: RequestBody; + const contentType = req.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + } else { + const form = await req.formData(); + body = { + tier: typeof form.get('tier') === 'string' ? (form.get('tier') as string) : undefined, + quantity: form.get('quantity') ? Number(form.get('quantity')) : undefined, + }; + } + + const tier = body.tier; + if (typeof tier !== 'string' || !BUYABLE_SLUGS.has(tier as TierSlug)) { + return NextResponse.json({ error: 'Invalid or unbuyable tier' }, { status: 400 }); + } + const tierSlug = tier as Exclude; + + const priceId = STRIPE_PRICE_IDS[tierSlug]; + if (!priceId) { + return NextResponse.json( + { error: 'Checkout not yet configured for this tier. Run scripts/stripe/sync-products.ts.' }, + { status: 503 }, + ); + } + + const tierConfig = TIERS.find((t) => t.slug === tierSlug); + if (!tierConfig) { + return NextResponse.json({ error: 'Tier missing from config' }, { status: 500 }); + } + + const rawQuantity = body.quantity ?? tierConfig.defaultQuantity ?? 1; + const quantity = Math.max(1, Math.min(100, Math.floor(rawQuantity))); + + const origin = getOrigin(req); + const stripe = getStripe(); + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + line_items: [ + { + price: priceId, + quantity, + ...(tierConfig.adjustableQuantity + ? { adjustable_quantity: { enabled: true, minimum: 1, maximum: 100 } } + : {}), + }, + ], + success_url: `${origin}/thanks?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/pricing`, + metadata: { ngaf_tier_slug: tierSlug }, + payment_intent_data: { metadata: { ngaf_tier_slug: tierSlug } }, + }); + + if (!session.url) { + return NextResponse.json({ error: 'Stripe did not return a checkout URL' }, { status: 502 }); + } + + return NextResponse.redirect(session.url, { status: 303 }); +} From ff6b0b5334646eacef3b84d45ec8c21de7987d8b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:10:25 -0700 Subject: [PATCH 07/15] feat(website): add /thanks page for Checkout success returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tells buyers their @ngaf/chat license token will be emailed within a few minutes, links to installation docs and support. Static server component; reads no query params at runtime — Stripe handles the session_id reconciliation via webhook in the minting service. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/src/app/thanks/page.spec.tsx | 40 +++++++++++++ apps/website/src/app/thanks/page.tsx | 71 +++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 apps/website/src/app/thanks/page.spec.tsx create mode 100644 apps/website/src/app/thanks/page.tsx diff --git a/apps/website/src/app/thanks/page.spec.tsx b/apps/website/src/app/thanks/page.spec.tsx new file mode 100644 index 000000000..466f94a9e --- /dev/null +++ b/apps/website/src/app/thanks/page.spec.tsx @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import ThanksPage from './page'; + +vi.mock('../../components/ui/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../../components/ui/Section', () => ({ + Section: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../../components/ui/Eyebrow', () => ({ + Eyebrow: ({ children }: { children: React.ReactNode }) => {children}, +})); +vi.mock('../../components/ui/Button', () => ({ + Button: ({ children, href }: { children: React.ReactNode; href?: string }) => + {children}, +})); + +describe('ThanksPage', () => { + it('renders the payment-received heading', () => { + render(); + expect(screen.getByRole('heading', { level: 1, name: 'Thanks for your purchase.' })).toBeTruthy(); + }); + + it('mentions provideChat() activation', () => { + render(); + expect(screen.getByText(/provideChat\(\)/)).toBeTruthy(); + }); + + it('links to installation docs and contact', () => { + render(); + expect(screen.getByRole('link', { name: 'Installation docs' }).getAttribute('href')) + .toBe('/docs/chat/getting-started/installation'); + expect(screen.getByRole('link', { name: 'Contact support' }).getAttribute('href')) + .toBe('/contact'); + }); +}); diff --git a/apps/website/src/app/thanks/page.tsx b/apps/website/src/app/thanks/page.tsx new file mode 100644 index 000000000..1dd98470f --- /dev/null +++ b/apps/website/src/app/thanks/page.tsx @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +import { tokens } from '@ngaf/design-tokens'; +import { Container } from '../../components/ui/Container'; +import { Section } from '../../components/ui/Section'; +import { Eyebrow } from '../../components/ui/Eyebrow'; +import { Button } from '../../components/ui/Button'; +import { createPageMetadata } from '../../lib/site-metadata'; + +export const metadata = createPageMetadata({ + title: 'Payment received — Threadplane', + description: 'Your @ngaf/chat license token will be emailed shortly.', + pathname: '/thanks', + type: 'website', +}); + +export default function ThanksPage() { + return ( +
+ +
+ Payment received +

+ Thanks for your purchase. +

+

+ Your @ngaf/chat license token will be emailed to the address on your receipt within a few minutes. Paste it into your app's provideChat() config to activate. +

+

+ If you don't see the email within 10 minutes, check spam or contact us. +

+
+ + +
+
+
+
+ ); +} From b851fb5fcc11dad060f0946683c28f750fb45250 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:12:46 -0700 Subject: [PATCH 08/15] feat(website): pricing grid posts to Stripe Checkout for paid tiers - Tier data now sourced from pricing/tiers.config.ts (single source of truth shared with the Stripe sync script). - Paid tiers (Indie / Developer Seat / App Deployment) submit a POST form to /api/checkout/session which 303-redirects to Stripe-hosted Checkout. - Community keeps the npm link; Enterprise keeps /contact. - Tracking events unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/pricing/PricingGrid.tsx | 293 ++++++++---------- 1 file changed, 131 insertions(+), 162 deletions(-) diff --git a/apps/website/src/components/pricing/PricingGrid.tsx b/apps/website/src/components/pricing/PricingGrid.tsx index 4bb5e922b..823a04c39 100644 --- a/apps/website/src/components/pricing/PricingGrid.tsx +++ b/apps/website/src/components/pricing/PricingGrid.tsx @@ -8,102 +8,46 @@ import { Button } from '../ui/Button'; import { Eyebrow } from '../ui/Eyebrow'; import { trackCtaClick } from '../../lib/analytics/client'; import type { CtaId } from '../../lib/analytics/events'; +import { TIERS, type TierConfig } from '../../../../../pricing/tiers.config'; -interface Plan { - name: string; - price: string; - period: string; - features: readonly string[]; - highlight: boolean; - cta: string; - ctaHref: string; - ctaId: CtaId; - ctaExternal?: boolean; +interface PlanCta { + readonly cta: string; + readonly ctaId: CtaId; + /** Set for tiers that route to Stripe via a POST form. */ + readonly stripeBuyable?: boolean; + /** Set for tiers that link directly (community = npm, enterprise = /contact). */ + readonly ctaHref?: string; + readonly ctaExternal?: boolean; } -const PLANS: readonly Plan[] = [ - { - name: 'Community / Noncommercial', - price: 'Free', - period: 'forever', - features: [ - 'Personal, student, academic, nonprofit, demo', - 'Source access', - 'Noncommercial use', - 'Commercial evaluation (30 days)', - 'License: PolyForm Noncommercial 1.0.0', - ], - highlight: false, +const CTAS: Record = { + community: { cta: 'Start free', - ctaHref: 'https://www.npmjs.com/package/@ngaf/chat', ctaId: 'pricing_tier_community', + ctaHref: 'https://www.npmjs.com/package/@ngaf/chat', ctaExternal: true, }, - { - name: 'Indie Commercial', - price: '$149', - period: '/year', - features: [ - '1 developer', - '1 commercial app', - 'Unlimited end users', - 'Commercial license', - 'Best for: solo devs, indie products, consultants with one app', - ], - highlight: false, + indie: { cta: 'Buy indie license', - ctaHref: '/contact?source=pricing_tier_indie', ctaId: 'pricing_tier_indie', + stripeBuyable: true, }, - { - name: 'Developer Seat', - price: '$299', - period: '/developer/year', - features: [ - 'Commercial use', - 'Unlimited end users', - 'Dev / staging / production', - 'Apps owned by your org', - 'Best for: startups & growing teams', - ], - highlight: true, + developer_seat: { cta: 'Buy developer seat', - ctaHref: '/contact?source=pricing_tier_developer_seat', ctaId: 'pricing_tier_developer_seat', + stripeBuyable: true, }, - { - name: 'App Deployment', - price: '$1,499', - period: '/app/year', - features: [ - 'Unlimited developers', - '1 production app', - 'Unlimited end users', - 'Procurement-friendly', - 'Best for: agencies, CI/CD-heavy teams', - ], - highlight: false, + app_deployment: { cta: 'License an app', - ctaHref: '/contact?source=pricing_tier_app_deployment', ctaId: 'pricing_tier_app_deployment', + stripeBuyable: true, }, - { - name: 'Enterprise', - price: 'Custom', - period: 'starting at $10k/year', - features: [ - 'Custom contract & SLA', - 'Procurement support', - 'Security review', - 'Multi-app licensing', - 'Priority + private support channel', - ], - highlight: false, + enterprise: { cta: 'Contact sales', - ctaHref: '/contact?source=pricing_tier_enterprise', ctaId: 'pricing_tier_enterprise', + ctaHref: '/contact?source=pricing_tier_enterprise', }, -]; +}; export function PricingGrid() { return ( @@ -118,99 +62,124 @@ export function PricingGrid() { margin: '0 auto', }} > - {PLANS.map((plan) => ( - - {plan.name} -

- {plan.price} -

-

{ + const cta = CTAS[tier.slug]; + return ( + - {plan.period} -

-
    - {plan.features.map((feature) => ( -
  • -
  • + ))} +
+ {cta.stripeBuyable ? ( +
+ + +
+ ) : ( + + )} +
+ ); + })} From aedc3cfc61668fa3f41354f7a665583c93a70189 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:15:00 -0700 Subject: [PATCH 09/15] chore(eslint): allow pricing/tiers.{config,generated} cross-project imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/website (PricingGrid + /api/checkout/session route) and scripts/stripe (sync-products) both import the repo-root pricing/ config files. They live outside any Nx project on purpose — they're the source of truth shared between display and Stripe sync. The @nx/enforce-module-boundaries rule needs an allow entry to permit this; the alternative is making pricing/ an Nx library, which is overkill for two config files. Co-Authored-By: Claude Opus 4.7 (1M context) --- eslint.config.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index d8a38127d..69024a5d5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,6 +23,10 @@ export default [ allow: [ '^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$', '^.*/libs/cockpit-(docs|registry|shell|testing|ui)/src/index$', + // Repo-root pricing/ config files are shared between + // apps/website and scripts/stripe; they live outside any + // Nx project on purpose. + '^.*/pricing/tiers\\.(config|generated)$', ], depConstraints: [ { From 5efdfae2bdef81909e096e4a30f40a8b634cd5e7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:18:36 -0700 Subject: [PATCH 10/15] feat(stripe): idempotent products + prices sync script Reads pricing/tiers.config.ts and ensures each stripeBuyable tier has a Stripe product (matched by metadata.ngaf_tier_slug) and exactly one active one-time USD price. Writes pricing/tiers.generated.ts with the resulting price IDs. Re-runnable; stale prices are archived when unit_amount changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/stripe/sync-products.spec.ts | 79 +++++++++++++++++++ scripts/stripe/sync-products.ts | 114 +++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 scripts/stripe/sync-products.spec.ts create mode 100644 scripts/stripe/sync-products.ts diff --git a/scripts/stripe/sync-products.spec.ts b/scripts/stripe/sync-products.spec.ts new file mode 100644 index 000000000..09a6a5dfb --- /dev/null +++ b/scripts/stripe/sync-products.spec.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi } from 'vitest'; +import type Stripe from 'stripe'; +import { syncProducts } from './sync-products'; + +function stubStripe(opts: { + productSearch?: Stripe.Product[]; + priceList?: Stripe.Price[]; +} = {}): Stripe { + const products = { + search: vi.fn().mockResolvedValue({ data: opts.productSearch ?? [] }), + create: vi.fn().mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: `prod_new_${name.replace(/\W+/g, '_')}`, name, active: true })), + update: vi.fn().mockImplementation((id: string, body: Stripe.ProductUpdateParams) => + Promise.resolve({ id, ...body, active: true })), + }; + const prices = { + list: vi.fn().mockResolvedValue({ data: opts.priceList ?? [] }), + create: vi.fn().mockImplementation((body: Stripe.PriceCreateParams) => + Promise.resolve({ id: `price_new_${body.unit_amount}`, ...body })), + update: vi.fn().mockImplementation((id: string) => Promise.resolve({ id, active: false })), + }; + return { products, prices } as unknown as Stripe; +} + +describe('syncProducts', () => { + it('creates a new product and price when none exist', async () => { + const stripe = stubStripe(); + const ids = await syncProducts(stripe); + expect(Object.keys(ids).sort()).toEqual(['app_deployment', 'developer_seat', 'indie']); + expect(ids.indie.startsWith('price_new_14900')).toBe(true); + }); + + it('reuses an existing product and matching active price', async () => { + const existingIndieProduct = { + id: 'prod_existing_indie', + name: 'Indie Commercial', + active: true, + } as Stripe.Product; + const existingIndiePrice = { + id: 'price_existing_indie', + product: 'prod_existing_indie', + unit_amount: 14900, + currency: 'usd', + type: 'one_time', + active: true, + } as Stripe.Price; + const stripe = stubStripe({ + productSearch: [existingIndieProduct], + priceList: [existingIndiePrice], + }); + const ids = await syncProducts(stripe); + expect(ids.indie).toBe('price_existing_indie'); + }); + + it('archives a stale price when unit_amount no longer matches and creates a new one', async () => { + const staleIndiePrice = { + id: 'price_stale_indie', + product: 'prod_existing_indie', + unit_amount: 9900, + currency: 'usd', + type: 'one_time', + active: true, + } as Stripe.Price; + const existingIndieProduct = { + id: 'prod_existing_indie', + name: 'Indie Commercial', + active: true, + } as Stripe.Product; + const stripe = stubStripe({ + productSearch: [existingIndieProduct], + priceList: [staleIndiePrice], + }); + const ids = await syncProducts(stripe); + expect(ids.indie.startsWith('price_new_14900')).toBe(true); + // @ts-expect-error vitest mock typing + expect(stripe.prices.update).toHaveBeenCalledWith('price_stale_indie', { active: false }); + }); +}); diff --git a/scripts/stripe/sync-products.ts b/scripts/stripe/sync-products.ts new file mode 100644 index 000000000..6e1294214 --- /dev/null +++ b/scripts/stripe/sync-products.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +/** + * Idempotent Stripe products + prices sync. + * + * Reads pricing/tiers.config.ts and ensures each `stripeBuyable: true` tier + * has a Stripe product (matched by metadata.ngaf_tier_slug) and exactly one + * active one-time price. Writes the resulting price IDs to + * pricing/tiers.generated.ts. + * + * Usage: + * STRIPE_SECRET_KEY=sk_test_... pnpm tsx scripts/stripe/sync-products.ts + * + * Re-running is safe: products are matched by metadata, prices are reused if + * the unit_amount matches, otherwise the old price is archived and a new one + * created. + */ +import Stripe from 'stripe'; +import fs from 'node:fs'; +import path from 'node:path'; +import { BUYABLE_TIERS, type TierConfig } from '../../pricing/tiers.config'; + +const METADATA_KEY = 'ngaf_tier_slug'; + +async function findOrCreateProduct(stripe: Stripe, tier: TierConfig): Promise { + const search = await stripe.products.search({ + query: `metadata['${METADATA_KEY}']:'${tier.slug}'`, + limit: 1, + }); + const existing = search.data[0]; + if (existing) { + if (existing.name !== tier.name || existing.active === false) { + return stripe.products.update(existing.id, { name: tier.name, active: true }); + } + return existing; + } + return stripe.products.create({ + name: tier.name, + metadata: { [METADATA_KEY]: tier.slug }, + }); +} + +async function findOrCreatePrice( + stripe: Stripe, + product: Stripe.Product, + tier: TierConfig, +): Promise { + if (tier.priceCents === null) { + throw new Error(`Tier ${tier.slug} has null priceCents but is marked stripeBuyable`); + } + const prices = await stripe.prices.list({ product: product.id, active: true, limit: 10 }); + const match = prices.data.find( + (p) => p.unit_amount === tier.priceCents && p.currency === 'usd' && p.type === 'one_time', + ); + if (match) return match; + + // Archive any active prices that don't match (one active price per tier). + for (const stale of prices.data) { + await stripe.prices.update(stale.id, { active: false }); + } + + return stripe.prices.create({ + product: product.id, + currency: 'usd', + unit_amount: tier.priceCents, + metadata: { [METADATA_KEY]: tier.slug }, + }); +} + +function renderGeneratedFile(idsBySlug: Record): string { + const entries = Object.entries(idsBySlug) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)},`) + .join('\n'); + return `// SPDX-License-Identifier: MIT +// Generated by scripts/stripe/sync-products.ts. Do not edit by hand. +import type { TierSlug } from './tiers.config'; + +export const STRIPE_PRICE_IDS: Partial, string>> = { +${entries} +}; +`; +} + +export async function syncProducts(stripe: Stripe): Promise> { + const idsBySlug: Record = {}; + for (const tier of BUYABLE_TIERS) { + const product = await findOrCreateProduct(stripe, tier); + const price = await findOrCreatePrice(stripe, product, tier); + idsBySlug[tier.slug] = price.id; + console.log(`✓ ${tier.slug}: product=${product.id} price=${price.id} (${tier.priceCents}¢)`); + } + return idsBySlug; +} + +async function main(): Promise { + const key = process.env['STRIPE_SECRET_KEY']; + if (!key || !key.startsWith('sk_')) { + throw new Error('STRIPE_SECRET_KEY must be set and begin with sk_'); + } + const stripe = new Stripe(key, { apiVersion: '2026-04-22.dahlia' }); + const ids = await syncProducts(stripe); + const outPath = path.join(process.cwd(), 'pricing', 'tiers.generated.ts'); + fs.writeFileSync(outPath, renderGeneratedFile(ids)); + console.log(`\nWrote ${outPath}`); +} + +// Only run when executed directly (not when imported by tests). +// tsx compiles to CJS so `require.main === module` is reliable here. +if (require.main === module) { + main().catch((err: unknown) => { + console.error(err); + process.exit(1); + }); +} From 2757afef2fdb22cf6ed84783d3f98d0dbd7ba1b0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:19:38 -0700 Subject: [PATCH 11/15] chore(website): add checkout analytics events + Stripe env example Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 3 +++ apps/website/.env.example | 3 +++ apps/website/src/lib/analytics/events.ts | 2 ++ 3 files changed, 8 insertions(+) diff --git a/.env.example b/.env.example index e988e8440..02a953dda 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,6 @@ NEXT_PUBLIC_COCKPIT_INGEST_HOST= # Production: https://examples.threadplane.ai # Leave empty in dev — wildcard '*' is used. NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN= + +# Stripe — see scripts/stripe/sync-products.ts and src/app/api/checkout/session/route.ts +STRIPE_SECRET_KEY=sk_test_… diff --git a/apps/website/.env.example b/apps/website/.env.example index ce2ca8ae2..e73696366 100644 --- a/apps/website/.env.example +++ b/apps/website/.env.example @@ -20,3 +20,6 @@ RESEND_NOTIFY_TO=hello@cacheplane.ai # Loops.so (https://loops.so — free tier: 1,000 contacts) LOOPS_API_KEY= + +# Stripe — see scripts/stripe/sync-products.ts and src/app/api/checkout/session/route.ts +STRIPE_SECRET_KEY=sk_test_… diff --git a/apps/website/src/lib/analytics/events.ts b/apps/website/src/lib/analytics/events.ts index 8ade2d162..dbaa31aa9 100644 --- a/apps/website/src/lib/analytics/events.ts +++ b/apps/website/src/lib/analytics/events.ts @@ -20,6 +20,8 @@ export const analyticsEvents = { docsSidebarSectionToggle: 'docs:sidebar_section_toggle', blogCtaClick: 'blog:cta_click', blogCopyCodeClick: 'blog:copy_code_click', + marketingCheckoutStarted: 'marketing:checkout_started', + marketingCheckoutSucceeded: 'marketing:checkout_succeeded', } as const; export type AnalyticsEventName = (typeof analyticsEvents)[keyof typeof analyticsEvents]; From e405db0ee9c49f16acbfb6ee99bb88851e109e60 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 17:23:11 +0000 Subject: [PATCH 12/15] chore(docs): regenerate api docs --- apps/website/content/docs/agent/api/api-docs.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index a3132bf8d..dd21d361e 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -1447,7 +1447,7 @@ { "name": "LangGraphThreadsConfig", "kind": "interface", - "description": "Configuration consumed by LangGraphThreadsAdapter. Provide\nvia LANGGRAPH_THREADS_CONFIG (typically in app.config.ts):\n\n```ts\nproviders: [\n { provide: LANGGRAPH_THREADS_CONFIG, useValue: {\n apiUrl: environment.langGraphApiUrl,\n titleMetadataKey: 'thread_title',\n }},\n],\n```", + "description": "Configuration consumed by LangGraphThreadsAdapter. Provide\nvia LANGGRAPH_THREADS_CONFIG (typically in app.config.ts):\n\n```ts\nproviders: [\n { provide: LANGGRAPH_THREADS_CONFIG, useValue: {\n apiUrl: environment.langGraphApiUrl,\n }},\n],\n```\n\nThe adapter expects backends to write the thread title to\n`metadata.title`. Spec 2026-05-19-llm-generated-labels-design.md\noriginally proposed `metadata.thread_title` for cockpit caps but\nwe converged on `title` to match the canonical demo and avoid a\nper-cap configuration knob.", "properties": [ { "name": "apiUrl", @@ -1460,12 +1460,6 @@ "type": "string", "description": "Fallback label for threads whose title hasn't been written yet\n (e.g. created but never sent). Defaults to `'Untitled'`.", "optional": true - }, - { - "name": "titleMetadataKey", - "type": "string", - "description": "Metadata key the backend writes the thread title to. Two\n conventions exist in the wild:\n - `'title'` — legacy / canonical demo\n - `'thread_title'` — spec 2026-05-19-llm-generated-labels-design\n Defaults to `'thread_title'`.", - "optional": true } ], "examples": [] From 935092c74cf4bb0bcd720f6878f5e768197f753f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:30:49 -0700 Subject: [PATCH 13/15] docs(superpowers): plan for licensing verification runtime (PR C) 6 tasks: runLicenseCheck idempotency bug fix + regression test; inject CACHEPLANE_LICENSE_PUBLIC_KEY into publish.yml; add minting-deploy job to ci.yml mirroring existing Vercel deploy patterns; document provideChat({license}) in libs/chat/README; wire examples/chat/angular to read optional license token; final verification + operational checklist for PR description. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-05-21-licensing-verification-runtime.md | 578 ++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-licensing-verification-runtime.md diff --git a/docs/superpowers/plans/2026-05-21-licensing-verification-runtime.md b/docs/superpowers/plans/2026-05-21-licensing-verification-runtime.md new file mode 100644 index 000000000..0f7f43cfa --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-licensing-verification-runtime.md @@ -0,0 +1,578 @@ +# Licensing Verification Runtime (PR C) 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:** Close the loop on the `@ngaf/chat` relicense: get the production public key into the published bundle by wiring `CACHEPLANE_LICENSE_PUBLIC_KEY` into `publish.yml`; deploy the existing `apps/minting-service` to Vercel via CI so Stripe webhooks land somewhere live; fix a known idempotency bug in `runLicenseCheck`; document the `provideChat({ license })` convention; and wire one example app to read a license string from a build-time env so the verify path is exercised in routine CI. + +**Architecture:** Mostly operational and infrastructure work — the crypto, minting code, and Stripe handler all already exist. Three repo edits: a one-line workflow env addition, a new ~30-line `minting-deploy` CI job mirroring the existing `deploy` job patterns, and a small `run-license-check.ts` fix. Plus library docs and an example wiring that's a no-op when `NGAF_LICENSE_TOKEN` is unset. + +**Tech Stack:** GitHub Actions (existing `.github/workflows/{publish,ci}.yml`), Vercel CLI (deploy step), Vitest (the existing licensing spec), Angular 20 + Vite environment (example app build-time defines). + +**Reference:** Spec at `docs/superpowers/specs/2026-05-20-licensing-verification-runtime-design.md`. + +--- + +## File map + +- **Modify:** `libs/licensing/src/lib/run-license-check.ts` — fix the idempotency bug (return cached `LicenseStatus`, not the constant `'licensed'`). +- **Modify:** `libs/licensing/src/lib/run-license-check.spec.ts` — add a regression test for the idempotency fix. +- **Modify:** `.github/workflows/publish.yml` — add `env: CACHEPLANE_LICENSE_PUBLIC_KEY: ${{ secrets.CACHEPLANE_LICENSE_PUBLIC_KEY }}` to the build step that runs `nx ... build ...licensing`. +- **Modify:** `.github/workflows/ci.yml` — add a `minting-deploy` job parallel to the existing website/cockpit/examples/demo deploys. +- **Modify:** `libs/chat/README.md` — add a short "Using a commercial license" section. +- **Modify:** `examples/chat/angular/src/app/app.config.ts` — add a `provideChat({ license })` provider that reads from a build-time define. +- **Modify:** `examples/chat/angular/src/environments/environment.ts` and `environment.development.ts` — add `license?: string` to the environment type if not present. +- **Modify:** `examples/chat/angular/project.json` — wire a Vite/Webpack define for `NGAF_LICENSE_TOKEN` from env at build time. + +**Operational (not code; document in PR description):** +- Set GH secret `VERCEL_MINTING_PROJECT_ID = prj_3x6SBua2bmAk374uFrp0MdqZSe9u`. +- In Vercel UI: assign `minting.threadplane.ai` to the `threadplane-minting-service` project. +- In Stripe Dashboard: point the live webhook endpoint at `https://minting.threadplane.ai/api/stripe-webhook`. + +No changes to `libs/chat/src/`, `libs/render/`, `libs/agent/`, `libs/langgraph/`, `libs/ag-ui/`, `libs/a2ui/`, `libs/telemetry/`, `libs/design-tokens/`, the cockpit, the website, or any other example app. + +--- + +## Task 1: Fix `runLicenseCheck` idempotency bug + +**Files:** +- Modify: `libs/licensing/src/lib/run-license-check.ts` +- Modify: `libs/licensing/src/lib/run-license-check.spec.ts` + +**Background:** The current `runLicenseCheck` has a `done: Set` keyed by `${package}|${token}`. On a second call with the same key, it returns the constant string `'licensed'` regardless of what the actual status was. This means: a no-token first call returns `'missing'` (correctly), but a no-token second call returns `'licensed'` (incorrectly). The fix is to cache the computed `LicenseStatus` on the dedup record and return *that*. + +- [ ] **Step 1: Write the failing regression test** + +Use Edit on `libs/licensing/src/lib/run-license-check.spec.ts`. Add this test inside the existing `describe('runLicenseCheck', () => { ... })` block (before the closing `});` brace; if there's already an `it('is idempotent...')` style test, place this one immediately after it): + +```ts + it('returns the cached actual status on repeat calls, not a constant', async () => { + // No-token first call: status should be 'missing' (production) or + // 'noncommercial' (dev). Force the production posture so we get 'missing'. + const result1 = await runLicenseCheck({ + package: '@ngaf/chat', + token: undefined, + publicKey: kp.publicKey, + isNoncommercial: false, + warn, + }); + expect(result1).toBe('missing'); + + // Second call with the same (package, token) tuple: must return the + // same status that was computed, not the literal 'licensed'. + const result2 = await runLicenseCheck({ + package: '@ngaf/chat', + token: undefined, + publicKey: kp.publicKey, + isNoncommercial: false, + warn, + }); + expect(result2).toBe('missing'); + }); +``` + +- [ ] **Step 2: Run the test and confirm it fails as expected** + +Run from repo root: `npx vitest run libs/licensing/src/lib/run-license-check.spec.ts 2>&1 | tail -20` +Expected: the new test fails with `Expected: "missing" / Received: "licensed"`. + +- [ ] **Step 3: Apply the fix** + +Use Edit on `libs/licensing/src/lib/run-license-check.ts`. Replace the existing `done` set declaration and the cached-path return with a `Map` that stores the computed status: + +Find: + +```ts +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); +``` + +Replace with: + +```ts +const done = new Map(); + +export async function runLicenseCheck( + options: RunLicenseCheckOptions, +): Promise { + const key = `${options.package}|${options.token ?? ''}`; + const cached = done.get(key); + if (cached !== undefined) { + // Idempotent: re-running with identical inputs returns the same status + // that was computed on the first call (not a hard-coded 'licensed'). + return cached; + } +``` + +Then find the last line of the function: + +```ts + emitNag(evaluated, { package: options.package, warn: options.warn }); + + return evaluated.status; +} +``` + +Replace with: + +```ts + emitNag(evaluated, { package: options.package, warn: options.warn }); + + done.set(key, evaluated.status); + return evaluated.status; +} +``` + +Then update the test-only reset function. Find: + +```ts +/** @internal testing hook only. */ +export function __resetRunLicenseCheckStateForTests(): void { + done.clear(); +} +``` + +Confirm `done.clear()` still works — it's a `Map` now, so `.clear()` is still valid. No change needed to that function. + +- [ ] **Step 4: Run the test to confirm pass** + +Run: `npx vitest run libs/licensing/src/lib/run-license-check.spec.ts 2>&1 | tail -10` +Expected: all tests pass. + +- [ ] **Step 5: Run the broader licensing test suite for regressions** + +Run: `npx nx run licensing:test 2>&1 | tail -8` +Expected: `Successfully ran target test for project licensing`. + +- [ ] **Step 6: Commit** + +```bash +git add libs/licensing/src/lib/run-license-check.ts libs/licensing/src/lib/run-license-check.spec.ts +git commit -m "$(cat <<'EOF' +fix(licensing): runLicenseCheck idempotency returns cached status + +Previously, repeat calls with the same (package, token) tuple short- +circuited to the literal 'licensed' regardless of what was actually +computed on the first call. That hid 'missing' / 'expired' / 'tampered' +statuses from any caller that invoked the check twice. + +Switches the dedup `Set` to a `Map` and +returns the cached actual status. Adds a regression test for the +no-token path. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Wire `CACHEPLANE_LICENSE_PUBLIC_KEY` into `publish.yml` + +**Files:** +- Modify: `.github/workflows/publish.yml` + +**Background:** The repo already has a GH Actions secret `CACHEPLANE_LICENSE_PUBLIC_KEY` (set 2026-04-30) and the licensing build's prebuild script `libs/licensing/scripts/generate-public-key.mjs` reads it as an env var. But no workflow currently injects the secret as an env var for that step, so published `@ngaf/chat` bundles ship with the dev fixture key. Fix: add the env injection to the build step. + +- [ ] **Step 1: Read the build step** + +Run: `grep -n "nx run-many\|build\|licensing" .github/workflows/publish.yml | head -20` +Confirm there's a build step around line 51 invoking `npx nx run-many -t lint,test,build --projects=$NPM_PUBLISHABLE_PROJECTS`. The `licensing` project is in that list (per `NPM_PUBLISHABLE_PROJECTS=chat,langgraph,ag-ui,render,a2ui,licensing,telemetry`). + +- [ ] **Step 2: Read the surrounding YAML for indentation** + +Run: `sed -n '40,60p' .github/workflows/publish.yml` +Capture the exact indentation of the `run:` line — the `env:` block must be a sibling. + +- [ ] **Step 3: Add the env injection** + +Use Edit on `.github/workflows/publish.yml`. Find the lint/test/build step (the one with `run: npx nx run-many -t lint,test,build --projects=$NPM_PUBLISHABLE_PROJECTS --skip-nx-cache`). Add an `env:` block at the same indentation level as the `run:` key. The final shape of the step should look like (preserving exact indentation from your sed output): + +```yaml + - name: Lint, test, and build publishable libraries + env: + CACHEPLANE_LICENSE_PUBLIC_KEY: ${{ secrets.CACHEPLANE_LICENSE_PUBLIC_KEY }} + run: npx nx run-many -t lint,test,build --projects=$NPM_PUBLISHABLE_PROJECTS --skip-nx-cache +``` + +(If the existing step already has an `env:` block, add the new line inside it instead of creating a duplicate `env:`.) + +- [ ] **Step 4: Validate YAML** + +Run: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/publish.yml')); print('ok')"` +Expected: `ok`. If you see a YAMLError, your indentation is off — re-check Step 2's sed output. + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/publish.yml +git commit -m "$(cat <<'EOF' +ci(publish): inject CACHEPLANE_LICENSE_PUBLIC_KEY into licensing build + +The GH secret existed since 2026-04-30 but was never referenced by any +workflow. As a result, the published @ngaf/chat bundle baked in the +dev-fixture public key from libs/licensing/fixtures/dev-public-key.hex +— meaning real license tokens signed by the minting service would not +verify in consumer apps. + +This single-line addition wires the secret into the env block of the +build step that runs `nx ... build ... licensing`. The existing +prebuild script (libs/licensing/scripts/generate-public-key.mjs) +already reads the env var and emits the prod hex into +license-public-key.generated.ts. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Add `minting-deploy` job to `ci.yml` + +**Files:** +- Modify: `.github/workflows/ci.yml` + +**Background:** Five Vercel projects deploy from CI today: `threadplane` (website), `threadplane-cockpit`, `threadplane-examples`, `threadplane-demo`, and a sixth that varies. The `threadplane-minting-service` Vercel project exists (project ID `prj_3x6SBua2bmAk374uFrp0MdqZSe9u`) and has all needed runtime env vars set — it's just not in the CI deploy step. We add it parallel to the existing patterns. + +- [ ] **Step 1: Read the existing demo-deploy step as the template** + +Run: `sed -n '545,580p' .github/workflows/ci.yml` +This is the `threadplane-demo` deploy block — confirm the structure: `mkdir -p .vercel` → write `.vercel/project.json` → `vercel pull` → `vercel build` (optional; demo uses `assemble-demo.ts` first) → `vercel deploy --prebuilt --prod`. The minting service has its own `apps/minting-service/vercel.json` so we can skip the assemble step. + +- [ ] **Step 2: Find where to insert the new job** + +Run: `grep -n "^\s*deploy:\|^\s*[a-z-]*-deploy:\|^\s*production-smoke:" .github/workflows/ci.yml` +This locates the job boundaries. Insert the new `minting-deploy:` job AFTER the existing demo deploy block and BEFORE the `production-smoke:` job (so `production-smoke` can `needs:` it later). + +- [ ] **Step 3: Add the minting-deploy job** + +Use Edit on `.github/workflows/ci.yml` to insert this new job at the position identified in Step 2. The exact indentation of `minting-deploy:` must match the existing top-level jobs (typically 2 spaces). + +```yaml + minting-deploy: + name: Minting service deploy + needs: [library] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + steps: + - uses: actions/checkout@v6.0.2 + - name: Use Node.js 22 + uses: actions/setup-node@v6.0.0 + with: + node-version: 22 + - name: Install pnpm + uses: pnpm/action-setup@v4.1.0 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Deploy minting service to Vercel (production) + working-directory: apps/minting-service + run: | + mkdir -p .vercel + cat > .vercel/project.json < +EOF +)" +``` + +--- + +## Task 4: Add "Using a commercial license" section to `libs/chat/README.md` + +**Files:** +- Modify: `libs/chat/README.md` + +- [ ] **Step 1: Find the right insertion point** + +Run: `grep -n "^## " libs/chat/README.md | head -10` +The README opens with the source-available framing and a top-level "Commercial use" section (added in PR A). After that, there are sections for runtime adapters, install, usage, etc. + +Insert the new section "## Using a commercial license" immediately after the existing "## Commercial use" section and before whatever comes next (likely "## Runtime adapters" or "## Install"). + +- [ ] **Step 2: Add the section** + +Use Edit on `libs/chat/README.md`. Find the existing block that ends the "Commercial use" section (the paragraph that ends `…the [Threadplane pricing page](https://threadplane.ai/pricing) for plans.`). Append the new section immediately after that paragraph: + +```markdown +## Using a commercial license + +After purchase, Threadplane emails a signed license token to the address on your receipt. Paste it into your app's `provideChat()` configuration: + +```typescript +// app.config.ts +import { ApplicationConfig } from '@angular/core'; +import { provideChat } from '@ngaf/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideChat({ + license: 'eyJ…', // The token from your purchase email. + }), + ], +}; +``` + +The library verifies the token's signature on boot. A missing, expired, or tampered token logs a `console.warn` advisory but does not block rendering — chat continues to work either way. Tokens are validated offline; no calls to Threadplane are made at runtime. + +The license string is safe to commit to source control if your repository is private, or to read from a build-time env var for public repositories: + +```typescript +declare const NGAF_LICENSE_TOKEN: string | undefined; + +providers: [ + provideChat({ + license: typeof NGAF_LICENSE_TOKEN === 'string' ? NGAF_LICENSE_TOKEN : undefined, + }), +], +``` + +(See `examples/chat/angular/` in the framework repo for a working example.) +``` + +(Note: the triple-backtick code blocks above are nested inside the markdown content of this plan file. When you write to `libs/chat/README.md`, the outer block delimiters should be plain triple-backticks; the inner `typescript` blocks remain as triple-backticks.) + +- [ ] **Step 3: Verify the headings** + +Run: `grep -n "^## " libs/chat/README.md | head -10` +Expected: the section "Using a commercial license" appears between "Commercial use" and the next pre-existing section. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/README.md +git commit -m "$(cat <<'EOF' +docs(chat): add "Using a commercial license" section to README + +Shows the provideChat({ license: '…' }) snippet customers paste after +purchase, plus the build-time-define variant for public repos. Notes +that verification is offline and advisory (console.warn, no render +block) — matches PR C's enforcement policy. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Wire `examples/chat/angular/` to read `NGAF_LICENSE_TOKEN` + +**Files:** +- Modify: `examples/chat/angular/src/app/app.config.ts` +- Modify: `examples/chat/angular/src/environments/environment.ts` +- Modify: `examples/chat/angular/src/environments/environment.development.ts` +- Modify: `examples/chat/angular/project.json` + +**Background:** Today `examples/chat/angular/src/app/app.config.ts` doesn't call `provideChat()` at all. To exercise the verify path during smoke testing, the demo needs a `provideChat({ license })` call that reads a build-time env var. When `NGAF_LICENSE_TOKEN` is unset (default), `license: undefined` is passed and behavior matches today's. This means the demo stays unlicensed in main, but a smoke-test session can inject a real token via env. + +- [ ] **Step 1: Add `license?: string` to environment types** + +Use Edit on `examples/chat/angular/src/environments/environment.ts`. Find the environment object (typically `export const environment = { … }`). Add `license: undefined as string | undefined,` as a field. If the file declares an explicit type (e.g., `export const environment: Environment = …`), update that interface to include `license?: string`. + +Do the same for `environment.development.ts`. Both files should expose the same shape. + +- [ ] **Step 2: Update app.config.ts** + +Use Edit on `examples/chat/angular/src/app/app.config.ts`. Find the imports block and the `providers` array. Modify the file as follows: + +Find the import line: + +```ts +import { LANGGRAPH_THREADS_CONFIG } from '@ngaf/langgraph'; +``` + +Add immediately after it: + +```ts +import { provideChat } from '@ngaf/chat'; +``` + +In the `providers: [ … ]` array, add `provideChat()` AS THE LAST entry (after the existing `LANGGRAPH_THREADS_CONFIG` provider): + +```ts + // Optional license token, populated by a Vite/Webpack build-time + // define from NGAF_LICENSE_TOKEN env var. Undefined in normal dev, + // letting @ngaf/chat run in advisory/noncommercial mode. Set for + // smoke tests against the verify path. + provideChat({ + license: environment.license, + }), +``` + +- [ ] **Step 3: Wire the build-time define in project.json** + +Read `examples/chat/angular/project.json`: + +``` +cat examples/chat/angular/project.json | python3 -m json.tool | head -60 +``` + +Find the `targets.build` config (likely under `executor: "@angular/build:application"` or similar). The simplest approach is **not** to use a build-time `define` (Angular's CLI doesn't support arbitrary Vite-style defines easily) but instead to use a `fileReplacements` strategy or to read at deploy time. Given the constraint, do the simplest thing: use the existing `environment.ts` / `environment.development.ts` pattern. + +For the **runtime** value, we let the deploy step set the value by overwriting the environment file. Add a one-line script step or a `prebuild` hook in `examples/chat/angular/project.json` that runs `node tools/inject-license-token.mjs` (we don't have to create this script in this PR — the env-var injection happens at the deploy level via `vercel build` reading `NGAF_LICENSE_TOKEN` env from Vercel project settings, then a small shell line in the deploy step that rewrites `environment.ts` before `vercel build`). + +**Concretely for this PR:** stop at the level of "the code reads `environment.license`; setting it during deploy is operational." Update the file map note accordingly — `examples/chat/angular/project.json` does NOT need a change in this PR. Smoke-test runbook in the spec explains how to set it locally during a smoke session. + +Remove `examples/chat/angular/project.json` from the **Files** list at the top of this task — it does not need to change. + +- [ ] **Step 4: Type-check + lint + test the example** + +From repo root: +``` +npx nx run examples-chat-angular:lint 2>&1 | tail -5 +npx nx run examples-chat-angular:build 2>&1 | tail -10 +``` +Both expected: success. + +- [ ] **Step 5: Confirm behavior is unchanged when license is undefined** + +Boot the dev server briefly: `npx nx serve examples-chat-angular --port 4400` in one shell. From another shell or browser, hit `http://localhost:4400/`. Confirm the page loads without errors. Stop the dev server. + +(With `environment.license = undefined`, `provideChat({ license: undefined })` triggers `runLicenseCheck` with no token; `inferNoncommercial()` should return `true` in dev mode, so the status is `'noncommercial'` and a single `console.warn` is emitted — that's the expected baseline.) + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/app.config.ts examples/chat/angular/src/environments/environment.ts examples/chat/angular/src/environments/environment.development.ts +git commit -m "$(cat <<'EOF' +feat(examples-chat): wire optional @ngaf/chat license into app.config + +Adds provideChat({ license: environment.license }) so a smoke-test +session can drop a real token into environment.ts and exercise the +verify path end-to-end. When license is undefined (the default in +main), the demo behaves identically to today: runLicenseCheck fires +once advisorily, status is 'noncommercial' under dev NODE_ENV, no +blocking. The token is intentionally absent from environment.ts so +the demo stays unlicensed in main. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Final verification + +**Files:** none (verification only). + +- [ ] **Step 1: Licensing tests** + +Run: `npx nx run licensing:test 2>&1 | tail -8` +Expected: `Successfully ran target test for project licensing`. + +- [ ] **Step 2: Chat tests (regression check — no library code changed but `provideChat` is exercised in the example)** + +Run: `npx nx run chat:test 2>&1 | tail -8` +Expected: `Successfully ran target test for project chat`. + +- [ ] **Step 3: Examples chat build** + +Run: `npx nx run examples-chat-angular:build 2>&1 | tail -8` +Expected: `Successfully ran target build for project examples-chat-angular`. + +- [ ] **Step 4: Workflow YAML validation (both files)** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/publish.yml')); yaml.safe_load(open('.github/workflows/ci.yml')); print('ok')" +``` +Expected: `ok`. + +- [ ] **Step 5: Scope check** + +```bash +git diff --name-only origin/main..HEAD | grep -vE '^(libs/licensing/|libs/chat/README\.md|\.github/workflows/(publish|ci)\.yml|examples/chat/angular/|docs/superpowers/)' | head +``` +Expected: empty. + +- [ ] **Step 6: Confirm operational gaps documented in PR description** + +When opening the PR, include a checklist of operational tasks that close the loop: + +``` +- [ ] Add GH secret `VERCEL_MINTING_PROJECT_ID = prj_3x6SBua2bmAk374uFrp0MdqZSe9u` +- [ ] Assign domain `minting.threadplane.ai` to the threadplane-minting-service Vercel project +- [ ] Point Stripe live-mode webhook at https://minting.threadplane.ai/api/stripe-webhook +- [ ] After PR merges, on first publish of @ngaf/chat: confirm dist/libs/licensing/fesm2022/*.mjs contains the prod public-key hex (not the dev fixture 793132582f3d…) +- [ ] Execute the end-to-end smoke test runbook from docs/superpowers/specs/2026-05-20-licensing-verification-runtime-design.md +``` + +--- + +## Self-review + +**Spec coverage:** +- Spec § Idempotency bug fix → Task 1 (both source fix + regression test). ✓ +- Spec § CI public-key injection → Task 2. ✓ +- Spec § Minting service CI deploy → Task 3. ✓ +- Spec § `provideChat({ license })` documentation → Task 4. ✓ +- Spec § Example wiring → Task 5. ✓ +- Spec § Smoke test runbook → not in plan; that's operational, runs from the spec post-merge. ✓ +- Spec § Operational tasks (GH secret, Vercel domain, Stripe webhook URL) → Task 6 Step 6 (documented in PR description). ✓ +- Spec out-of-scope items (origin allowlist, claims schema, nag UI, render block) → not in plan. ✓ + +**Placeholder scan:** No TBD/TODO. Task 5's Step 3 explicitly walks back the originally-planned `project.json` build-define change to "operational, not in this PR" — that's documented inline as a deliberate scope decision, not a placeholder. + +**Type consistency:** `LicenseStatus` import in Task 1 is consistent with the existing module. `provideChat`, `ChatConfig.license` consistent across Tasks 4 and 5. `environment.license` is the same field name in environment.ts, environment.development.ts, and app.config.ts. + +Plan complete. From 940a47b8d6912be453ff91de05940b1b3ea27e65 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:49:55 -0700 Subject: [PATCH 14/15] =?UTF-8?q?fix(website):=20drop=20stripe=20from=20pa?= =?UTF-8?q?ckage.json=20=E2=80=94=20npm=20ci=20needs=20lockfile=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding stripe to apps/website/package.json without regenerating package-lock.json broke npm ci in CI. The root workspace already ships stripe ^22.0.2, so the import resolves fine without the explicit declaration. (Memory: regenerating package-lock.json on macOS drops Linux @next/swc-* bindings and breaks CI.) Local build + tests still pass; this is a pure lockfile-hygiene fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/website/package.json b/apps/website/package.json index 86fb078d4..4cab1de69 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -14,7 +14,6 @@ "remark-gfm": "^4.0.1", "resend": "^6.10.0", "shiki": "*", - "stripe": "^22.0.2", "tailwind-merge": "^2.5.0" } } From 2a7aa95270b52a8acba5d88f4c917b6b4d62b83e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 11:21:31 -0700 Subject: [PATCH 15/15] fix(stripe): drop pinned apiVersion literal to survive patch drift CI installed stripe@22.0.x (LatestApiVersion = '2026-03-25.dahlia') while local pnpm had 22.1.x ('2026-04-22.dahlia'). The hardcoded literal failed the website build's TypeScript narrowing. Omitting apiVersion lets the Stripe lib use whichever default ships with the installed version. Account-level API version pinning still applies; we just don't override it from code. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/src/lib/stripe.ts | 5 ++++- scripts/stripe/sync-products.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/website/src/lib/stripe.ts b/apps/website/src/lib/stripe.ts index 1259260a9..a9a7666c5 100644 --- a/apps/website/src/lib/stripe.ts +++ b/apps/website/src/lib/stripe.ts @@ -17,5 +17,8 @@ export function getStripe(): Stripe { if (!key.startsWith('sk_')) { throw new Error('STRIPE_SECRET_KEY does not look like a Stripe secret key (must begin with "sk_")'); } - return new Stripe(key, { apiVersion: '2026-04-22.dahlia' }); + // apiVersion omitted so the bundled lib version's default is used. + // Pinning a literal breaks across stripe@22.0.x ↔ 22.1.x where the + // type narrows to different LatestApiVersion strings. + return new Stripe(key); } diff --git a/scripts/stripe/sync-products.ts b/scripts/stripe/sync-products.ts index 6e1294214..67d60e746 100644 --- a/scripts/stripe/sync-products.ts +++ b/scripts/stripe/sync-products.ts @@ -97,7 +97,7 @@ async function main(): Promise { if (!key || !key.startsWith('sk_')) { throw new Error('STRIPE_SECRET_KEY must be set and begin with sk_'); } - const stripe = new Stripe(key, { apiVersion: '2026-04-22.dahlia' }); + const stripe = new Stripe(key); const ids = await syncProducts(stripe); const outPath = path.join(process.cwd(), 'pricing', 'tiers.generated.ts'); fs.writeFileSync(outPath, renderGeneratedFile(ids));