How to Build a Self-Hosted Gumroad & Lemon Squeezy Replacement with an AI Agent
Digital creators and indie software teams shouldn't have to hand over 10% of every sale just to deliver a PDF, ZIP archive, or license key. Let's take a stab at crafting a careful plan, having an coding agent execute it from start to finish, and deploying our own self-hosted digital storefront. In this blueprint, you will build a production-grade sales engine complete with signature-verified Stripe Checkout webhooks, integer-cents pay-what-you-want and discount math, 60-second presigned private S3 downloads, Crockford Base32 license key activations, and automatic refund/dispute access revocation.
Part 1: A Great Application Starts from a Great Plan
If you open an AI coding agent and say, "Build me a Gumroad clone with Stripe Checkout and file downloads," it will happily scaffold a shiny product landing page and a "Buy Now" button in under a minute.
Unfortunately, the moment real buyers, email link scanners, or opportunistic attackers touch that naive prototype, everything breaks down:
- Spoofed Success URLs Grant Free Access: Naive agents fulfill orders when the browser hits
/checkout/success?order_id=123, letting anyone download your products for free without paying. - Duplicate Webhook & Out-of-Order Chaos: Stripe retries webhooks and can deliver
charge.refundedbeforecheckout.session.completed. Without an idempotent event ledger and strict state machine, buyers receive duplicate license keys or keep access after a refund. - Public Storage Buckets & Burned One-Time Links: Files get uploaded to public buckets, or corporate email link scanners pre-fetch GET links and burn through the buyer's download quota and one-time license key reveal before the buyer even opens the email.
- Floating-Point Money Bugs & String Concatenation: JavaScript float math turns
$19.99with a 15% discount into fractional cent errors, while PostgreSQLBIGINTcolumns return strings by default—turning"2900" + 100into"2900100"($29,001.00).
Instead of fixing those vulnerabilities after launch, we hand our agent a cold-build verified blueprint (v1.1.0) up front—locking in a 10-table PostgreSQL schema, HMAC-derived secrets with zero plaintext in the database, S1–S16 security tests, and a local Docker Compose stack that runs every test gate without needing a single cloud account.
Here's how you can use this tutorial
There are three flexible ways to work through this guide depending on your workflow:
- Download the Markdown Plan: Download gumroad-digital-sales-engine.plan.md directly into an empty directory and point your coding agent at it.
- Copy and Paste: Jump directly to Part 2 and copy the full plan, or open the raw verbatim prompt page, and paste it into Claude Code, Cursor, or Windsurf.
- Follow Along for Architectural Patterns: Read through each section to see how we structure idempotent Stripe webhooks, private S3 presigned URLs, and transactional email outboxes.
The core philosophy is simple: before your agent writes a single line of code, it has a battle-tested plan set up for success, the PromptScale way.
Part 2: Here Is the Plan We Can Give to Our Agent — Start to Finish
Below is the exact, cold-build verified specification (gumroad-digital-sales-engine.plan.md, Version 1.1.0). Every phase, Docker Compose service, S1–S16 security requirement, and Playwright browser test has been verified end-to-end.
# Blueprint: Digital Product Sales Engine (Gumroad Clone)
**Version:** 1.1.0 (Self-Contained Autonomous Phased Verification Edition, Cold-Build Verified)
**Target Agents:** Claude Code, Cursor, Windsurf, Devin
**Methodology:** Strict Planning Mode, Schema-First Raw SQL DDL, Idempotent Stripe Webhooks, Integer-Cents Money Math, Private Object Storage, 200-Line File Cap, Two-Gate Regression Testing
> **For humans:** save this file as `gumroad-digital-sales-engine.plan.md` in an empty directory and point a coding agent at it.
> **Prerequisites on the machine:** Node.js **22 LTS** (22.12+), pnpm, Docker (with Compose), and git. Every Definition of Done runs **without** a Stripe account: tests sign webhooks locally and e2e uses `stripe-mock`. A Stripe account in **test mode** plus the [Stripe CLI](https://docs.stripe.com/stripe-cli) are needed only for the optional manual smoke test (Phase 5). No live keys, ever, in this build.
> **Result:** a local, tested storefront you can run with `pnpm run db:up && pnpm run db:migrate && pnpm run db:seed && pnpm run dev` (add `pnpm run stripe:listen` for real test-mode payments).
---
## Execution Protocol for the Agent (Autonomous Phased Verification)
You are an autonomous principal engineer. Execute this plan sequentially from Phase 1 through Phase 5 without requiring manual human prompts between phases:
1. **Phased State Machine:** Implement strictly one phase at a time under Section 7. Never scaffold, modify, or jump ahead to future phases out of order.
2. **Automated Test Gate Verification:** At the end of each phase, run that phase's **Definition of Done (DoD)** commands. Every command must exit 0.
3. **Self-Healing Loop:** If a DoD command fails, read the stack trace, fix the implementation code (never the assertion), and re-run (up to 3 remediation cycles). If still failing, stop and report.
4. **Autonomous Progression:** When Phase [N] passes, log `[PHASE N COMPLETE - GATE PASSED]` to stdout and immediately transition to Phase [N+1].
5. **Final Delivery:** Stop only after Phase 5 passes. Deliver the Section 8 checklist, the final `pnpm run verify` output, and instructions to run the app (with `stripe listen` for real test-mode payments).
*(If the user explicitly asks for a checkpoint mode like "pause for review", pause after each gate and await approval.)*
### Operating Rules
1. **Don't ask clarifying questions.** Where this document is silent, choose the simplest option consistent with it and record it in `DECISIONS.md` (one line per decision).
2. **Never weaken a test to make it pass.** No skipping, deleting, or loosening assertions; no `@ts-ignore`, `eslint-disable`, or `any`.
3. **Commit after each phase** (`phase 3: checkout and price snapshots`). Phase 1 runs `git init` and ignores `.env` before the first commit.
4. **If the environment blocks you** (Docker not running, Node older than 22), stop and tell the user exactly what to do. **If a host port is taken** (5432, 3000, 8333, 1025/8025, 12111), pick the next free port, change it everywhere it appears (`PG_HOST_PORT`/URLs in `.env`, the `dev`/`start` scripts and `APP_ORIGIN`), and record it in `DECISIONS.md`. Don't mock the database or object storage to get around anything. The Stripe CLI is not needed for any DoD.
5. **Never commit secrets.** `.env` is gitignored; `.env.example` holds placeholders only. Never print `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, peppers, license keys, or access tokens to logs.
6. **Test mode only.** The app refuses to boot if `STRIPE_SECRET_KEY` does not start with `sk_test_` (S15).
7. **No runtime dependencies beyond Section 1** without a `DECISIONS.md` line explaining why.
8. **Hard limit: 200 lines per source file.** Split by responsibility as you approach it.
---
## 1. Executive Summary & Tech Stack Constraints
A self-hosted, single-creator digital product storefront: the seller uploads files, sets a fixed or pay-what-you-want price, optionally issues license keys and discount codes, and sells through Stripe Checkout. Payment is confirmed **only** by signature-verified, deduplicated Stripe webhooks; buyers receive a private library link that mints short-lived signed download URLs from a private bucket, capped per purchase. Full refunds and disputes revoke downloads and license keys automatically.
### Architectural Constraints:
- **Runtime & pins:** Node.js 22 LTS. Install these majors (the versions a cold build passed with): `[email protected]`, `stripe@^23`, `zod@^4`, `postgres@^4`, `drizzle-orm@^0.45`, `@aws-sdk/client-s3@^3.1161` + `@aws-sdk/s3-request-presigner@^3.1161`, `@node-rs/argon2@^2`, `nodemailer@^10`, `server-only`; dev `drizzle-kit@^0.31`, `vitest@^5`, `tsx@^4`, `@playwright/test@^1.64`, `@types/node@^22`, `@types/nodemailer`.
- **Framework:** Next.js 14 (App Router) with Tailwind. Route handlers that touch Stripe or storage declare `export const runtime = 'nodejs'` and `export const dynamic = 'force-dynamic'`. Route handlers read cookies from `req.headers` (not `next/headers`), so tests can call them directly. `next.config.mjs` sets `experimental: { instrumentationHook: true, serverComponentsExternalPackages: ["@node-rs/argon2", "postgres"] }` (without `postgres` there, `next build` fails in Terser on postgres-js's ESM entry).
- **Server-only modules:** every file in `src/lib/server/` starts with `import "server-only"` (except the pure `env-schema.ts`). `server-only` throws outside the `react-server` export condition, so scripts run as `tsx --env-file=.env --conditions=react-server …` (tsx does not load `.env` by itself) and `vitest.config.ts` sets `resolve.conditions` (and `ssr.resolve.conditions`) to `["react-server", "node", "import", "default"]`.
- **Language:** TypeScript 5+ (`strict: true`, `noUncheckedIndexedAccess: true`).
- **Database:** PostgreSQL 16 in Docker Compose (`app` db for dev, `app_test` db for tests and e2e). Every timestamp is `TIMESTAMPTZ DEFAULT now()` (never `timezone('utc', now())`).
- **Database Driver:** `postgres` (`postgres-js`) in `src/db/client.ts` (the only DB module) with `{ prepare: false }` so the same code runs behind transaction poolers (Supabase port 6543, Neon) and still supports interactive transactions with `SELECT ... FOR UPDATE`. postgres-js returns `BIGINT` as **strings** by default (`"2900" + 100` is `"2900100"`), so the client registers an `int8` type parser that returns a `number` and throws a `RangeError` outside `Number.isSafeInteger`.
- **ORM & Migrations:** the source of truth is the raw SQL migration `src/db/migrations/0001_init.sql` (CHECK constraints, partial unique indexes) applied by `src/db/migrate.ts`, which records applied files in a `_migrations` table (idempotent). Never use `drizzle-kit push`. Every statement spelled out in Section 4 (ledger `ON CONFLICT`, `FOR UPDATE`, conditional `UPDATE … RETURNING`) is a postgres-js tagged template. `src/db/schema.ts` is a typed Drizzle mirror of the DDL, used through `drizzle(sql)` for the simple dashboard CRUD in `dashboard-data.ts` and the product page read.
- **Payments:** `stripe` (official Node SDK), **test mode**, Stripe Checkout (`mode: 'payment'`), API version pinned in `src/lib/server/stripe.ts` to the SDK's default and recorded in `DECISIONS.md`. Webhooks verified with `stripe.webhooks.constructEvent(rawBody, signature, STRIPE_WEBHOOK_SECRET)` where `rawBody = await req.text()`. Optional `STRIPE_API_BASE` points the SDK at `stripe-mock` (parse it into `host`/`port`/`protocol`); the e2e server always sets it, so no test touches api.stripe.com.
- **Object Storage:** any S3-compatible store through `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner`. Construct `S3Client` with `forcePathStyle: true`, `requestChecksumCalculation: "WHEN_REQUIRED"`, and `responseChecksumValidation: "WHEN_REQUIRED"`. Since SDK 3.729 the default bakes a CRC32 of an *empty* body into presigned PUT URLs, and every browser upload of a real file then fails with 400. Locally: **SeaweedFS** (`chrislusf/seaweedfs`, S3 gateway, static identities file so anonymous access is denied). *(MinIO's community images were withdrawn from Docker Hub in 2025–2026, so the plan does not depend on them; any S3-compatible endpoint works by changing `S3_ENDPOINT`.)*
- **Email (local):** Mailpit (`axllent/mailpit`, SMTP 1025, UI/API 8025) via `nodemailer` with `connectionTimeout`/`greetingTimeout` 5 s and `socketTimeout` 10 s, through a transactional outbox table so webhook retries never send duplicate receipts.
- **Seller Auth:** built in, single seller. Email + password with argon2id (`@node-rs/argon2`), DB-backed sessions. No public signup; the seller is created by `pnpm run db:seed` (dev) or `pnpm run admin:create` (prod).
- **Buyer secrets (one design, applied everywhere):** the two secrets a buyer receives are **derived on demand from a server secret and never stored in plaintext**. Library token = base64url(`HMAC-SHA256(LIBRARY_TOKEN_SECRET, "library-token:v1:" + orderId)`) (32 bytes, 43 chars); `orders.access_token_hash` = its SHA-256, written at checkout. License key = Crockford-encode(`HMAC-SHA256(LICENSE_KEY_PEPPER, "license-key:v1:" + licenseKeyId)`); the DB holds only `key_hash` + `key_last4`. Both live in `src/lib/server/derive.ts`. The outbox recomputes them when it sends the receipt, and the one-time reveal recomputes the key, so no plaintext ever sits in a table or in an outbox payload. Rotating a secret invalidates every link or key derived from it (README says so).
- **Validation:** Zod everywhere input crosses a boundary (forms, route handlers, webhook payload fields you read, env).
- **Rate Limiting:** Postgres-backed fixed-window limiter (`rate_limits` table) behind a `RateLimiter` interface, so it works across instances with zero extra infra.
- **Dependency Injection:** core modules (`fulfill.ts`, `create-checkout.ts`, `licenses.ts`, `downloads.ts`, `outbox.ts`) are plain server modules with **no `"use server"` directive** and take `deps` (`sql`, `stripe`, `storage`, `mailer`, `clock`, `rateLimiter`, secrets). Thin route handlers and server actions pass production deps. Tests pass fakes for Stripe only; Postgres and S3 are always real.
- **Testing:** Vitest (Gate 1 unit, Gate 2 integration against Compose services, `fileParallelism: false` because suites share `app_test`) + Playwright (Chromium) for the browser flows (buyer purchase and seller upload).
- **File Limit:** Under 200 lines per source file. Test files may be split into focused suites.
### Environment variables (all listed in `.env.example`):
```
DATABASE_URL=postgres://app:app@localhost:5432/app
TEST_DATABASE_URL=postgres://app:app@localhost:5432/app_test
PG_HOST_PORT=5432 # compose host port for Postgres; keep in sync with the two URLs
APP_ORIGIN=http://localhost:3000
STRIPE_SECRET_KEY=sk_test_000000000000000000000000 # sk_test_ + letters/digits only (stripe-mock 401s on extra underscores)
STRIPE_WEBHOOK_SECRET=whsec_local_test_signing_secret # tests sign with this; `stripe listen` prints a real one
# STRIPE_API_BASE=http://localhost:12111 # optional: point the SDK at stripe-mock (e2e sets it automatically)
STORE_CURRENCY=usd # single store currency (Section 4.5)
S3_ENDPOINT=http://localhost:8333
S3_REGION=us-east-1
S3_BUCKET=products
S3_ACCESS_KEY_ID=local-access-key # must match seaweedfs/s3.json
S3_SECRET_ACCESS_KEY=local-secret-key-change-me # must match seaweedfs/s3.json
DOWNLOAD_URL_TTL_SECONDS=60
DEFAULT_DOWNLOAD_LIMIT=5
LICENSE_KEY_PEPPER=change-me-32-bytes-minimum-random-1 # openssl rand -base64 32
LIBRARY_TOKEN_SECRET=change-me-32-bytes-minimum-random-2 # openssl rand -base64 32
IP_HASH_SECRET=change-me-32-bytes-minimum-random-3 # openssl rand -base64 32
TRUST_PROXY=false # true only behind a proxy that sets X-Forwarded-For
SMTP_URL=smtp://127.0.0.1:1025 # 127.0.0.1: Node may resolve localhost to ::1 first
MAIL_FROM="Store <store@localhost>"
```
`src/lib/server/env-schema.ts` (pure Zod, no I/O) validates these; `src/lib/server/env.ts` caches the result. Rules: all required vars present (`STRIPE_API_BASE` optional); `LICENSE_KEY_PEPPER`, `LIBRARY_TOKEN_SECRET`, and `IP_HASH_SECRET` ≥ 32 chars (only these three; `whsec_…` and S3 placeholders have no length rule); `STRIPE_SECRET_KEY` prefixed `sk_test_`; `STRIPE_WEBHOOK_SECRET` prefixed `whsec_`; `STORE_CURRENCY` in the two-decimal allowlist (`usd, eur, gbp, cad, aud`); `DOWNLOAD_URL_TTL_SECONDS` 1–300. Errors name the variable and the rule, never the value. **Boot-time enforcement:** `src/instrumentation.ts` `register()` (Node runtime only) calls `parseEnv(process.env)` and `process.exit(1)` on failure, so `next start` refuses to boot with an `sk_live_` key instead of failing on the first request.
---
## 2. Relational Schema (PostgreSQL DDL)
> Apply via raw SQL in `src/db/migrations/0001_init.sql`. All money columns are `BIGINT` integer minor units (cents), read back as JS numbers through the `int8` parser in `src/db/client.ts` (Section 1).
```sql
-- gen_random_uuid() is built in on PostgreSQL 13+; no extensions required.
-- Seller (single-tenant admin)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE CHECK (email = lower(email)),
password_hash TEXT NOT NULL, -- argon2id
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE sessions (
id CHAR(64) PRIMARY KEY, -- sha256 hex of cookie token
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
-- Products
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(64) NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$'),
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
currency CHAR(3) NOT NULL CHECK (currency = lower(currency)),
price_cents BIGINT NOT NULL CHECK (price_cents > 0),
pwyw_enabled BOOLEAN NOT NULL DEFAULT FALSE,
pwyw_min_cents BIGINT CHECK (pwyw_min_cents > 0),
download_limit INT NOT NULL DEFAULT 5 CHECK (download_limit BETWEEN 1 AND 100),
license_enabled BOOLEAN NOT NULL DEFAULT FALSE,
license_activation_limit INT NOT NULL DEFAULT 3 CHECK (license_activation_limit BETWEEN 1 AND 1000),
is_published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- PWYW: price_cents is the suggested price; pwyw_min_cents is the floor
CHECK (NOT pwyw_enabled OR (pwyw_min_cents IS NOT NULL AND pwyw_min_cents <= price_cents))
);
-- Deliverable files (bytes live in the private bucket, never in Postgres)
CREATE TABLE product_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
storage_key TEXT NOT NULL UNIQUE, -- 'products/<product_id>/<uuid>' — never user-controlled
filename VARCHAR(255) NOT NULL, -- display + Content-Disposition only
content_type VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes > 0),
sha256 CHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_product_files_product ON product_files(product_id);
-- Discount codes with race-safe redemption caps
CREATE TABLE discount_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(40) NOT NULL UNIQUE CHECK (code ~ '^[A-Z0-9_-]{3,40}$'),
product_id UUID REFERENCES products(id) ON DELETE CASCADE, -- NULL = all fixed-price products
kind VARCHAR(10) NOT NULL CHECK (kind IN ('percent', 'fixed')),
percent_off INT CHECK (percent_off BETWEEN 1 AND 99),
amount_off_cents BIGINT CHECK (amount_off_cents > 0),
max_redemptions INT CHECK (max_redemptions > 0), -- NULL = unlimited
redemptions_count INT NOT NULL DEFAULT 0 CHECK (redemptions_count >= 0), -- reserved + consumed
starts_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK ((kind = 'percent' AND percent_off IS NOT NULL AND amount_off_cents IS NULL)
OR (kind = 'fixed' AND amount_off_cents IS NOT NULL AND percent_off IS NULL)),
CHECK (max_redemptions IS NULL OR redemptions_count <= max_redemptions),
CHECK (expires_at IS NULL OR starts_at IS NULL OR expires_at > starts_at)
);
-- Orders: immutable price snapshot + payment state machine
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
buyer_email TEXT NOT NULL CHECK (buyer_email = lower(buyer_email)),
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN
('pending','paid','expired','failed','refunded','disputed','needs_review')),
-- Snapshot captured at checkout creation; never recomputed from products
product_name_snapshot VARCHAR(255) NOT NULL,
currency CHAR(3) NOT NULL,
list_price_cents BIGINT NOT NULL CHECK (list_price_cents > 0),
subtotal_cents BIGINT NOT NULL CHECK (subtotal_cents > 0), -- list price, or PWYW amount
discount_code_id UUID REFERENCES discount_codes(id) ON DELETE SET NULL,
discount_cents BIGINT NOT NULL DEFAULT 0 CHECK (discount_cents >= 0),
tax_cents BIGINT NOT NULL DEFAULT 0 CHECK (tax_cents >= 0), -- 0 in v1 (Section 4.5)
total_cents BIGINT NOT NULL CHECK (total_cents > 0),
amount_refunded_cents BIGINT NOT NULL DEFAULT 0 CHECK (amount_refunded_cents >= 0),
stripe_checkout_session_id TEXT UNIQUE,
stripe_payment_intent_id TEXT UNIQUE,
access_token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 of the derived library token (Section 1), set at checkout
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (discount_cents < subtotal_cents),
CHECK (total_cents = subtotal_cents - discount_cents + tax_cents),
CHECK (amount_refunded_cents <= total_cents),
CHECK (status <> 'paid' OR paid_at IS NOT NULL)
);
CREATE INDEX idx_orders_product ON orders(product_id, created_at DESC);
CREATE INDEX idx_orders_email ON orders(buyer_email);
-- One reservation per order; released on expiry/failure
CREATE TABLE discount_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discount_code_id UUID NOT NULL REFERENCES discount_codes(id) ON DELETE CASCADE,
order_id UUID NOT NULL UNIQUE REFERENCES orders(id) ON DELETE CASCADE,
status VARCHAR(10) NOT NULL DEFAULT 'reserved' CHECK (status IN ('reserved','consumed','released')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Access rights created by fulfillment (exactly one per paid order)
CREATE TABLE entitlements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL UNIQUE REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
status VARCHAR(10) NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked')),
revoked_reason VARCHAR(20) CHECK (revoked_reason IN ('refund','dispute','manual')),
download_limit INT NOT NULL CHECK (download_limit > 0), -- snapshot of products.download_limit
downloads_used INT NOT NULL DEFAULT 0 CHECK (downloads_used >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
CHECK (downloads_used <= download_limit),
CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))
);
CREATE TABLE download_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entitlement_id UUID NOT NULL REFERENCES entitlements(id) ON DELETE CASCADE,
product_file_id UUID NOT NULL REFERENCES product_files(id) ON DELETE CASCADE,
ip_hmac CHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_download_events_entitlement ON download_events(entitlement_id, created_at DESC);
-- License keys: only the HMAC is stored; the plaintext is re-derived from (LICENSE_KEY_PEPPER, id) when needed
CREATE TABLE license_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL UNIQUE REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
key_hash CHAR(64) NOT NULL UNIQUE, -- HMAC-SHA256(LICENSE_KEY_PEPPER, normalized key)
key_last4 CHAR(4) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked')),
activation_limit INT NOT NULL CHECK (activation_limit > 0), -- snapshot
revealed_at TIMESTAMPTZ, -- set by the one-time reveal POST (Section 4.8)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE TABLE license_activations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_key_id UUID NOT NULL REFERENCES license_keys(id) ON DELETE CASCADE,
instance_id VARCHAR(200) NOT NULL, -- caller-supplied machine/install id
activated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deactivated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uq_active_instance ON license_activations(license_key_id, instance_id)
WHERE deactivated_at IS NULL;
-- Webhook idempotency ledger (dedupe on Stripe event id)
CREATE TABLE stripe_events (
id TEXT PRIMARY KEY, -- 'evt_...'
type TEXT NOT NULL,
livemode BOOLEAN NOT NULL,
stripe_created TIMESTAMPTZ NOT NULL,
order_id UUID REFERENCES orders(id) ON DELETE SET NULL,
outcome VARCHAR(20) NOT NULL DEFAULT 'ignored' CHECK (outcome IN
('applied','ignored','unmatched','needs_review')),
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_stripe_events_order ON stripe_events(order_id);
-- Transactional outbox: at most one email of each kind per order
CREATE TABLE email_outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
kind VARCHAR(20) NOT NULL CHECK (kind IN ('receipt','access_revoked')),
to_email TEXT NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')),
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
CONSTRAINT uq_outbox_order_kind UNIQUE (order_id, kind)
);
CREATE INDEX idx_outbox_pending ON email_outbox(created_at) WHERE status = 'pending';
-- Fixed-window rate limiter shared by all instances
CREATE TABLE rate_limits (
key TEXT NOT NULL,
window_start TIMESTAMPTZ NOT NULL,
count INT NOT NULL DEFAULT 0,
PRIMARY KEY (key, window_start)
);
```
The data access layer sets `updated_at = now()` on every update. Products with orders can be unpublished but never deleted (`ON DELETE RESTRICT`). Discount codes with redemptions are deactivated, not deleted. If you mirror the slug rule as an HTML `pattern`, write `[a-z0-9\-]` (Chrome compiles `pattern` with the `v` flag, where an unescaped `-` makes the class invalid and validation is silently dropped).
---
## 3. Modular File System Architecture (Hard 200-Line Cap)
```
├── docker-compose.yml # postgres:16, seaweedfs (S3 :8333), mailpit (:1025/:8025), stripe-mock (:12111)
├── docker/postgres/init.sql # CREATE DATABASE app_test OWNER app;
├── seaweedfs/s3.json # static identity (same keys as .env.example) -> anonymous access denied
├── .env.example .gitignore (+ .env) DECISIONS.md README.md
├── next.config.mjs # CSP + S8 headers, instrumentationHook, external packages (< 60 lines)
├── drizzle.config.ts vitest.config.ts playwright.config.ts
├── scripts/
│ ├── seed.ts # seller + 2 products + sample file + 1 code; exports seed() for e2e (< 120 lines)
│ ├── create-bucket.ts # CreateBucket via SDK, idempotent (< 50 lines)
│ ├── admin-create.ts # prod seller bootstrap (< 60 lines)
│ └── flush-outbox.ts # send pending emails (< 60 lines)
src/
├── instrumentation.ts # register(): parseEnv or process.exit(1) (S15) (< 20 lines)
├── middleware.ts # S14 Origin check, matcher /api/:path* (< 30 lines)
├── app/
│ ├── p/[slug]/page.tsx # Public product page + buy form (< 150 lines)
│ ├── checkout/success/page.tsx # Polls order status; NEVER fulfills; "check your email" (< 90 lines)
│ ├── library/[token]/page.tsx # Buyer library: files, masked license + reveal button, downloads left (< 160 lines)
│ ├── (auth)/login/page.tsx # Seller login (< 90 lines)
│ ├── dashboard/layout.tsx # requireSeller() or redirect to /login (< 40 lines)
│ ├── dashboard/page.tsx # Sales table + product list + "New product" (< 150 lines)
│ ├── dashboard/products/new/page.tsx # Create product form (< 60 lines)
│ ├── dashboard/products/[id]/page.tsx # Edit product + file upload + file list (< 180 lines)
│ ├── dashboard/discounts/page.tsx # List / create / activate-deactivate codes (< 150 lines)
│ ├── dashboard/actions.ts # Server Actions: saveProduct, addDiscount, toggleDiscount (< 120 lines)
│ └── api/
│ ├── checkout/route.ts # POST create Checkout Session -> 303 (< 60 lines)
│ ├── orders/[id]/status/route.ts # GET status for success page (< 50 lines)
│ ├── webhooks/stripe/route.ts # Raw body -> verify -> handleStripeEvent -> flush (< 70 lines)
│ ├── downloads/route.ts # POST {token,fileId} -> 303 to signed URL (< 70 lines)
│ ├── library/license/route.ts # POST {token} -> one-time license reveal (< 60 lines)
│ ├── uploads/presign/route.ts # Seller: presigned PUT (< 70 lines)
│ ├── uploads/finalize/route.ts # Seller: HeadObject + insert product_files (< 70 lines)
│ ├── auth/{login,logout}/route.ts # Seller session (< 70 lines each)
│ └── v1/licenses/{verify,activate,deactivate}/route.ts # Public license API (< 60 lines each)
├── components/
│ ├── store/ buy-form.tsx pwyw-input.tsx discount-field.tsx price-tag.tsx
│ ├── library/ file-list.tsx license-reveal.tsx
│ ├── dashboard/ product-form.tsx file-uploader.tsx sales-table.tsx discount-form.tsx
│ └── ui/ button.tsx field.tsx badge.tsx
├── lib/
│ ├── money/ money.ts parse-amount.ts pricing.ts # PURE (Gate 1)
│ ├── licenses/ key-format.ts # PURE: generate/normalize (Gate 1)
│ ├── orders/ state-machine.ts # PURE: transition table (Gate 1)
│ ├── stripe/ event-mapper.ts # PURE: Stripe.Event -> DomainEvent (Gate 1)
│ └── server/
│ ├── env-schema.ts (pure) env.ts stripe.ts storage.ts mailer.ts crypto.ts derive.ts client-ip.ts
│ ├── http.ts rate-limit.ts auth.ts session.ts seller.ts (requireSeller)
│ ├── dashboard-data.ts # seller-gated product/discount/sales data layer (< 160 lines)
│ ├── create-checkout.ts # snapshot + discount reservation + Stripe session (< 190 lines)
│ ├── handle-stripe-event.ts # dedupe ledger + linkage + dispatch (< 150 lines)
│ ├── fulfill.ts # pending->paid: entitlement, license, outbox; releaseDiscount (< 170 lines)
│ ├── revoke.ts # refund/dispute revocation (< 120 lines)
│ ├── downloads.ts # atomic count + presigned GET (< 110 lines)
│ ├── licenses.ts # verify/activate/deactivate/reveal (< 180 lines)
│ └── outbox.ts # enqueue + flush (< 120 lines)
└── db/
├── client.ts migrate.ts schema.ts # client.ts is the only DB module (no lib/server/db.ts)
└── migrations/0001_init.sql
tests/
├── unit/ money.test.ts parse-amount.test.ts pricing.test.ts key-format.test.ts
│ state-machine.test.ts event-mapper.test.ts env.test.ts # Gate 1
├── integration/ schema.test.ts checkout.test.ts webhook-idempotency.test.ts webhook-ordering.test.ts
│ revocation.test.ts downloads.test.ts storage-private.test.ts licenses.test.ts
│ discounts-race.test.ts auth.test.ts outbox.test.ts security-headers.test.ts # Gate 2
├── e2e/ global-setup.ts prepare-db.ts purchase.spec.ts seller.spec.ts
└── helpers/ db.ts stripe-fake.ts signed-event.ts factories.ts
```
**pnpm scripts** (every `tsx` call is `tsx --env-file=.env --conditions=react-server`, abbreviated `TSX` here):
- `dev` (`next dev -p 3000`), `build`, `start` (`next start -p 3000`), `typecheck` (`tsc --noEmit`), `lint` (`next lint`)
- `db:up` (`docker compose up -d --wait && TSX scripts/create-bucket.ts`), `db:migrate` (`TSX src/db/migrate.ts`), `db:seed` (`TSX scripts/seed.ts`), `admin:create`, `outbox:flush`
- `stripe:listen` (`stripe listen --forward-to localhost:3000/api/webhooks/stripe`; optional, manual smoke test only)
- `test:gate1` (`vitest run tests/unit`), `test:gate2` (`vitest run tests/integration`), `test:e2e` (`playwright test`)
- `verify` (typecheck → lint → gate1 → gate2 → build → e2e, fail fast)
---
## 4. State Machine & Business Logic Invariants
### 4.1 Payment is confirmed by webhooks only
- `checkout/success` reads `GET /api/orders/:id/status` and shows "Processing…" until `paid`, then "Paid: check your email for your library link". It never calls Stripe, never fulfills, and never shows the library link.
- Fulfillment triggers (and nothing else): `checkout.session.completed` with `payment_status = 'paid'`, `checkout.session.async_payment_succeeded`, and `payment_intent.succeeded`. All three route to the same idempotent `fulfill(orderId)`.
- `checkout.session.completed` with `payment_status = 'unpaid'` (delayed methods) is recorded as `ignored`, and fulfillment waits for `async_payment_succeeded`.
- `PAYMENT_FAILED` comes **only** from `checkout.session.async_payment_failed`. `payment_intent.payment_failed` is `IGNORED`, because a declined card inside Checkout can be retried on the same PaymentIntent, and failing the order there would strand a buyer who succeeds on the second try.
### 4.2 Webhook pipeline (`handle-stripe-event.ts`)
1. Route reads `rawBody = await req.text()`, verifies with `constructEvent`. Bad/missing signature → **400**, nothing written.
2. Reject `event.livemode === true` → 400 (test-mode build).
3. In **one transaction**: `INSERT INTO stripe_events (...) ON CONFLICT (id) DO NOTHING RETURNING id`. No row → duplicate → commit, return **200**. A concurrent duplicate blocks on the PK until the first commits, then sees the conflict.
4. Map with the pure `event-mapper.ts` to a `DomainEvent` (`PAYMENT_SUCCEEDED{amount, currency} | PAYMENT_FAILED | SESSION_EXPIRED | REFUNDED{amountRefunded, amount} | DISPUTE_OPENED | DISPUTE_CLOSED{status} | IGNORED`) plus a ref `{orderId, paymentIntentId}`.
5. Resolve the order: `metadata.order_id` (sessions, payment intents) → `stripe_payment_intent_id` (charges, disputes) → injected `stripe.paymentIntents.retrieve(pi)` reading `metadata.order_id` (must be a UUID). Not found → outcome `unmatched`, return 200 (so `stripe trigger` test events don't retry forever).
6. `SELECT ... FROM orders WHERE id = $1 FOR UPDATE`. **Order linkage (S4):** for `checkout.session.*` events, `data.object.id` must equal `orders.stripe_checkout_session_id`; a mismatched `PAYMENT_SUCCEEDED` on a pending order → `needs_review` (no access), any other mismatched event is a no-op. Otherwise apply the pure `transition(order, domainEvent)` and run its effects in the same transaction.
7. Set `stripe_events.outcome` and `order_id`: `needs_review` when the transition emits `FLAG_REVIEW`; `applied` when it changes status or emits any other effect; `ignored` otherwise. Commit.
8. Any thrown error rolls back the whole transaction (including the ledger row) and returns **500** so Stripe retries.
9. After commit, the route calls `flushOutbox()` best-effort (errors are logged, never change the 200).
### 4.3 Order transition table (pure, `state-machine.ts`)
| From \ Event | PAYMENT_SUCCEEDED | PAYMENT_FAILED / SESSION_EXPIRED | REFUNDED (full) | REFUNDED (partial) | DISPUTE_OPENED | DISPUTE_CLOSED won | DISPUTE_CLOSED lost |
|---|---|---|---|---|---|---|---|
| `pending` | → `paid` + **fulfill** (if amount + currency match, else `needs_review` + FLAG_REVIEW) | → `failed` / `expired`, release discount | → `refunded` (pre-empts fulfillment; reservation kept) | record amount, stay | → `disputed` | no-op | no-op |
| `paid` | no-op (idempotent) | no-op | → `refunded` + **revoke** | record amount, keep access | → `disputed` + **revoke** | no-op | no-op |
| `refunded` | no-op (never re-fulfill) | no-op | no-op | no-op | no-op | no-op | no-op |
| `disputed` | no-op | no-op | → `refunded` | record amount | no-op | stay `disputed` + FLAG_REVIEW (seller restores manually) | no-op (stays `disputed`) |
| `expired`/`failed` | → `needs_review` + FLAG_REVIEW (paid after expiry) | no-op | → `refunded` | record amount | → `disputed` | no-op | no-op |
| `needs_review` | no-op | no-op | → `refunded` | record amount | → `disputed` | no-op | no-op |
`needs_review` means "money moved but no access was granted"; it is never fulfilled automatically. The dashboard sales table shows it as a badge; the seller resolves it by refunding in the Stripe Dashboard (which moves it to `refunded`). "Record amount" sets `amount_refunded_cents = GREATEST(current, LEAST(event amount, total_cents))`. A reservation is released only on `PAYMENT_FAILED`/`SESSION_EXPIRED`; refunds keep the code use counted. Terminal-state wins make the outcome **independent of delivery order**: e.g. `charge.refunded` arriving before `checkout.session.completed` yields `refunded` with zero entitlements.
### 4.4 Fulfillment (`fulfill.ts`, inside the webhook transaction)
- The mapper puts the paid amount and currency on the domain event (`session.amount_total` for Checkout events, `payment_intent.amount_received` for `payment_intent.succeeded`). Fulfillment requires `amount === order.total_cents` and `currency === order.currency`. On a mismatch the order goes to `needs_review` and no access is granted.
- Set `status = 'paid'`, `paid_at`, `stripe_payment_intent_id` (COALESCE). Insert `entitlements` (`ON CONFLICT (order_id) DO NOTHING`), mark the discount reservation `consumed`, and if `license_enabled` insert `license_keys` with a fresh UUID `id` and the `key_hash`/`key_last4` of the key derived from that id (`ON CONFLICT (order_id) DO NOTHING`). Enqueue the `receipt` email (`ON CONFLICT (order_id, kind) DO NOTHING`). Email sending never runs inside the DB transaction.
- **Outbox flush (`outbox.ts`):** loop up to N times; each iteration is its own transaction that selects one row `WHERE status = 'pending' AND attempts < 5 AND id <> ALL(<ids already tried this pass>) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, composes the mail, sends, and marks it `sent`. A row that fails gets `attempts + 1` and `last_error`, becomes `failed` at 5 attempts, and **is not retried again in the same pass**. The receipt (only while the order is still `paid`; otherwise mark it sent with `last_error = 'skipped'`) contains `APP_ORIGIN/library/<derived token>` and, if licensed, the derived key. `access_revoked` tells the buyer access ended. `pnpm run outbox:flush` (cron in prod) retries the rest.
### 4.5 Money
- All amounts are integer minor units (`BIGINT` in SQL, `number` in JS via the `int8` parser), validated with `Number.isSafeInteger`. No floats anywhere in `lib/money` (`0.1 + 0.2` must never be on a code path).
- `parseAmount("12.50") → 1250`. Accepts `^\d{1,7}(\.\d{1,2})?$` only; rejects `"1e3"`, `"12.505"`, `"-1"`, `" "`, and `"0x10"`.
- `STRIPE_MIN_CHARGE_CENTS = { usd: 50, eur: 50, cad: 50, aud: 50, gbp: 30 }`. Percent discount = `floor(subtotal * percent / 100)` (integer math, rounded in buyer-unfavorable direction by < 1 cent; documented). **Every** discount (percent or fixed) must leave `total ≥ STRIPE_MIN_CHARGE_CENTS[currency]`, else reject `DISCOUNT_TOO_LARGE`. 100%-off/free orders are out of scope for v1.
- PWYW: `subtotal = parseAmount(input)`. Below `pwyw_min_cents` → reject with `AMOUNT_BELOW_MINIMUM` (never clamp silently). `pwyw_min_cents` itself must be ≥ `STRIPE_MIN_CHARGE_CENTS[currency]` (app-level Zod check). Discount codes do not apply to PWYW products.
- **Price snapshot:** `create-checkout.ts` copies name, currency, list price, subtotal, discount, and total into `orders` and passes `unit_amount = total_cents` via `price_data` to Stripe. Editing the product afterwards never changes an existing order.
- **Single currency:** every product's currency must equal `STORE_CURRENCY`. Multi-currency and zero-decimal currencies (JPY, KRW) are out of scope.
- **Tax:** v1 prices are **tax-exclusive and no tax is collected** (`automatic_tax` disabled, `tax_cents = 0`). The plan does not make the seller tax-compliant; see the README's production checklist.
### 4.6 Discount redemption caps (race-safe)
Inside the checkout transaction (`$now` is the injected `clock()`, also used for the session's `expires_at`, so tests can fake time):
```sql
UPDATE discount_codes SET redemptions_count = redemptions_count + 1
WHERE code = $1 AND is_active
AND (max_redemptions IS NULL OR redemptions_count < max_redemptions)
AND (starts_at IS NULL OR starts_at <= $now) AND (expires_at IS NULL OR expires_at > $now)
AND (product_id IS NULL OR product_id = $2)
RETURNING *;
```
No row → `DISCOUNT_UNAVAILABLE`. Insert `discount_redemptions(status='reserved')`. Stripe sessions are created with `expires_at = $now + 31 min` (Stripe's minimum is 30 minutes). On `SESSION_EXPIRED`/`PAYMENT_FAILED` set `released` and decrement `redemptions_count` (guarded `WHERE status = 'reserved'` so a duplicate never double-decrements). The CHECK constraint is the backstop.
### 4.7 Secure delivery
- The bucket is private. `storage_key` is generated server-side (`products/<productId>/<uuid>`); filenames never form a key.
- `POST /api/downloads {token, fileId}` (form POST so email link scanners and prefetchers can't burn downloads; the token is a bearer secret in the body, no cookie): hash the token → order → entitlement, then atomically:
`UPDATE entitlements SET downloads_used = downloads_used + 1 WHERE id = $1 AND status = 'active' AND downloads_used < download_limit RETURNING downloads_used`. No row → 403 `DOWNLOAD_LIMIT_REACHED` or `ACCESS_REVOKED` (JSON for `Accept: application/json`, a small HTML page otherwise).
- Then insert `download_events` and respond **303** to a presigned `GetObject` URL with `expiresIn = DOWNLOAD_URL_TTL_SECONDS` (≤ 300, default 60) and `ResponseContentDisposition: attachment; filename="<ascii fallback>"; filename*=UTF-8''<encoded>`. The browser follows that redirect to the S3 origin, which is why S16 lists it in `form-action`.
- Known limit: a presigned URL is a bearer URL until it expires. Keep the TTL short and say so in the README.
- Seller uploads: `uploads/presign` returns a presigned `PutObject` (5-minute TTL, max 2 GB) signed with `signableHeaders: new Set(["content-type", "content-length"])`, so the URL's `X-Amz-SignedHeaders` is `content-length;content-type;host` and carries **no** `x-amz-checksum-*` params. The browser `fetch(url, {method: "PUT"})`s the file directly to the S3 origin (S16 `connect-src`), then `uploads/finalize` runs `HeadObject` to confirm size before inserting `product_files`.
- Bucket CORS: SeaweedFS's S3 gateway answers browser preflights for any origin, so local uploads work with no setup. AWS S3 and R2 need an explicit bucket CORS rule allowing `PUT` (and `GET`) from `APP_ORIGIN` with the `content-type` header; the README's deploy section says so.
### 4.8 License keys
- Format: 25 Crockford base32 chars in 5 groups (`XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`), 5 bits per char via `byte & 31` (unbiased, since 256 is a multiple of 32; no rejection sampling needed). `generateKey(bytes)` takes 25 bytes from an injected source: `crypto.randomBytes` in tests, the HMAC output from `derive.ts` in production. Generated keys never contain `I`, `L`, `O`, or `U`.
- `normalize()`: uppercase, strip spaces/hyphens, map `O→0`, `I/L→1`; reject anything else.
- Stored as `HMAC-SHA256(LICENSE_KEY_PEPPER, normalized)` plus `key_last4`; the plaintext is re-derived when needed (Section 1). The buyer gets the key in the receipt email and can reveal it **once** on the library page: the page renders only the masked key and a "Reveal license key" button that `POST`s `{token}` to `/api/library/license` (never on GET, so link scanners and prefetch can't use up the reveal). The route runs `UPDATE license_keys SET revealed_at = now() WHERE order_id = $1 AND status = 'active' AND revealed_at IS NULL RETURNING id` and returns `{ key }` with `Cache-Control: no-store`; already revealed → 409 `{ error: "ALREADY_REVEALED", last4 }`; revoked → 403. Reissuing keys is out of scope for v1 (`license_keys.order_id` is UNIQUE).
- `POST /api/v1/licenses/verify {productId, licenseKey}` → `{valid, activationsUsed, activationLimit}`. Unknown key, wrong product, or malformed input all return the same `{valid:false}` body and status.
- `activate {productId, licenseKey, instanceId}`: lock the key row `FOR UPDATE`, count active activations, insert if `< activation_limit` (re-activating an existing active instance is idempotent and consumes nothing) else 409 `ACTIVATION_LIMIT_REACHED`. `deactivate` sets `deactivated_at`.
- Rate limits: 10 req/min per IP and 30 req/hour per key hash across the three endpoints → 429 with `Retry-After`. The IP comes from `client-ip.ts`: `X-Forwarded-For` only when `TRUST_PROXY=true`, otherwise a constant, so without a proxy the per-IP limit is effectively global (README says so).
- Revoked keys (refund/dispute) return `{valid:false}` and cannot activate.
### 4.9 Seller dashboard
- `dashboard/layout.tsx` calls `requireSeller()` (session cookie → seller, else redirect to `/login`); every function in `dashboard-data.ts` takes the seller and checks it again (authorization lives in the data layer, not only the layout).
- Product create (`/dashboard/products/new`) and edit (`/dashboard/products/[id]`) and discount create/activate/deactivate (`/dashboard/discounts`) are **Server Actions** in `dashboard/actions.ts` (Next.js checks Origin against Host for Server Actions). They validate with Zod (price via `parseAmount`, currency = `STORE_CURRENCY`, slug regex, PWYW floor) and return `{ error }` or redirect. Discount codes with redemptions are deactivated, never deleted.
- The sales table lists orders newest first: status badge (including `needs_review`), buyer email, total, refunded amount.
---
## 5. Security Requirements
Each requirement must have at least one test named with its id (e.g. `it("S4: amount mismatch never grants access")`).
| Id | Requirement |
|---|---|
| S1 | Webhooks are verified against the raw body with `constructEvent`. A missing or invalid `Stripe-Signature` (or a timestamp outside the SDK tolerance) returns 400 and writes nothing. |
| S2 | Event-id dedupe: replaying the same `evt_` (sequentially and 5× concurrently) produces exactly one state change, one entitlement, one license key, and one outbox row. |
| S3 | Only the payment-confirmed events in 4.1 fulfill. The success page, the `success_url` query string, and client calls can never grant access or reveal the library link. |
| S4 | Fulfillment checks the amount, currency, and order linkage (session id, 4.2 step 6); any mismatch yields `needs_review` and no access. `livemode: true` events are rejected. |
| S5 | A full refund or opened dispute revokes the entitlement and license key in the same transaction. Subsequent downloads return 403 and verify returns `{valid:false}`. |
| S6 | The bucket is private: an unsigned `GET` of a real object key (and an unsigned bucket listing) returns 403, tested against the running SeaweedFS. A presigned PUT only accepts the signed size and content type. |
| S7 | Download URLs have a TTL ≤ 300 s (default 60). Downloads use POST. The per-entitlement limit holds under 10 concurrent requests. |
| S8 | Library tokens (32 bytes, HMAC-derived from `LIBRARY_TOKEN_SECRET`) are stored only as SHA-256; license keys only as HMAC with a pepper. Neither is stored in plaintext anywhere (including `email_outbox`) or appears in logs. `/library/*` sends `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. |
| S9 | License API rate limits (10/min/IP, 30/h/key) return 429; responses don't distinguish unknown vs wrong-product keys. |
| S10 | The activation limit holds under 5 concurrent activations with distinct instance ids (limit 3 → exactly 3 succeed). |
| S11 | Discount caps hold under 10 concurrent checkouts (cap 3 → exactly 3 reserved). Expiry releases a reservation exactly once. |
| S12 | Money paths use integers only. Zod rejects non-integer cents and the malformed amount strings in 4.5. DB CHECKs reject an inconsistent `total_cents`. BIGINT columns read back as `number`, never `string`. |
| S13 | The seller password is argon2id. The session token is 32 bytes in an `HttpOnly`, `SameSite=Lax` cookie (`Secure` in production) with only its SHA-256 stored. Login failures return a generic 401, rate limited to 5/min per IP+email. |
| S14 | Requests that authenticate with the seller session cookie must come from the app: in `src/middleware.ts` (matcher `/api/:path*`), non-GET/HEAD/OPTIONS requests require `Origin === APP_ORIGIN` (403 `BAD_ORIGIN` otherwise). Cookie-less routes are **exempt**: `/api/webhooks/stripe`, `/api/v1/licenses/*`, and the bearer-token library routes `/api/downloads` and `/api/library/*`. They must be exempt, because S8's `no-referrer` makes Chrome send `Origin: null` on the library page's form POSTs, and a strict check would refuse every download. Dashboard Server Actions rely on Next's built-in Origin/Host check. |
| S15 | Env validation (Section 1): `next start`/`next dev` refuse to boot (`instrumentation.ts`, exit 1) unless `STRIPE_SECRET_KEY` starts with `sk_test_` and `LICENSE_KEY_PEPPER`, `LIBRARY_TOKEN_SECRET`, `IP_HASH_SECRET` are ≥ 32 chars. `createStripe()` also refuses non-`sk_test_` keys. The seed refuses to run when `NODE_ENV=production`. |
| S16 | Security headers on all pages (`next.config.mjs` `headers()`), with `S3_ORIGIN = new URL(S3_ENDPOINT).origin` computed in the config: `default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self' https://checkout.stripe.com S3_ORIGIN; connect-src 'self' S3_ORIGIN; script-src 'self' 'unsafe-inline' (+ 'unsafe-eval' in development only); style-src 'self' 'unsafe-inline'; img-src 'self' data:`. `form-action` must list Stripe **and** the S3 origin because browsers enforce it on redirects: the buy form 303s to Stripe, and the download form 303s to the presigned URL. `connect-src` lists the S3 origin for the browser's presigned PUT. `'unsafe-inline'` is required by Next 14's inline bootstrap scripts without nonces (record in `DECISIONS.md`). Also send `X-Content-Type-Options: nosniff`. Server modules import `server-only`. Unexpected errors return a generic `500 {"error":"INTERNAL"}` without details or stack traces. |
---
## 6. Two-Gate Test Specification
**Gate 1 — pure unit (no Docker, no network), `pnpm run test:gate1`:**
- `money.test.ts`: `formatCents(10050,'usd') → "$100.50"`; safe-integer guard; sums of 1,000 random cent values match BigInt reference.
- `parse-amount.test.ts`: the accept/reject table from 4.5.
- `pricing.test.ts`: 15% off 1999 → discount 299, total 1700; fixed 2500 off 2900 with usd min 50 → total 400 OK; fixed 2899 off 2900 → `DISCOUNT_TOO_LARGE`; 99% off 100 → `DISCOUNT_TOO_LARGE` (the floor applies to percent codes too); gbp floor is 30; PWYW below floor rejected; PWYW + code rejected.
- `key-format.test.ts`: 10,000 keys unique, all match the format, none contain `I/L/O/U`; `normalize(k.toLowerCase()) === normalize(k)` for generated keys; `O/0` and `I/L/1` equivalence; garbage rejected; HMAC differs with different peppers; `deriveLicenseKey(pepper, id)` is deterministic and differs per id.
- `state-machine.test.ts`: every cell of the 4.3 table, including the `needs_review` row and lost disputes.
- `event-mapper.test.ts`: fixtures (`tests/unit/fixtures/*.json`, shaped like real Stripe events) for each handled type, plus an unknown type → `IGNORED`. `checkout.session.completed` with `payment_status:'unpaid'` → `IGNORED`. `payment_intent.payment_failed` → `IGNORED`. `charge.refunded` with `amount_refunded === amount` → full, and with less → partial.
- `env.test.ts`: `sk_live_...` rejected; each of the three short secrets rejected; the `.env.example` values pass; errors never contain the value.
**Gate 2 — integration against Compose Postgres + SeaweedFS + Mailpit, `pnpm run test:gate2`:**
`vitest.config.ts` loads `.env` (`process.loadEnvFile`), sets `DATABASE_URL = TEST_DATABASE_URL` and `NODE_ENV=test` for the test process, and `tests/helpers/db.ts` refuses any database whose name doesn't end in `_test`, migrates once, and `TRUNCATE … CASCADE`s every table except `_migrations` between tests. Events are built with real Stripe-shaped payloads and signed via `stripe.webhooks.generateTestHeaderString({ payload, secret })`, then POSTed to the route handler's exported `POST`. Only outbound Stripe API calls (`checkout.sessions.create`, `paymentIntents.retrieve`) use `tests/helpers/stripe-fake.ts`.
- `schema`: inconsistent `total_cents`, `downloads_used > download_limit`, duplicate `key_hash`, `redemptions_count > max_redemptions` are all rejected; `price_cents` reads back as `typeof "number"` (S12).
- `webhook-idempotency`: same event 5× via `Promise.all` → 1 `applied` row, 1 entitlement, 1 license, 1 outbox row; bad signature → 400 and 0 rows (S1, S2).
- `webhook-ordering`: `payment_intent.succeeded` then `checkout.session.completed` → one fulfillment; `charge.refunded` **before** `checkout.session.completed` → `refunded`, 0 entitlements; `checkout.session.expired` after `paid` → still `paid`; amount mismatch → `needs_review` with outcome `needs_review`; a Checkout event for a different session id → `needs_review`, 0 entitlements (S3, S4).
- `revocation`: paid → full `charge.refunded` → download 403, verify `{valid:false}`, reveal 403, one `access_revoked` outbox row; partial refund keeps access; `charge.dispute.created` revokes (S5).
- `storage-private`: unsigned GET and unsigned listing → 403; presigned GET → 200 with `Content-Disposition: attachment` and the UTF-8 filename; presigned GET fails after a 1 s TTL (wait 2.5 s); tampered signature → 403; **presigned PUT**: URL has `X-Amz-SignedHeaders=content-length;content-type;host` and no `x-amz-checksum-*` param, a different body size → 403, a different `Content-Type` → 403, the signed size/type → 200, and the object is still private afterwards (S6, S7).
- `downloads`: limit 5, 10 concurrent POSTs → exactly 5 × 303 and 5 × 403; `downloads_used = 5` (S7).
- `licenses`: verify/activate/deactivate happy path; 5 concurrent activations limit 3 → 3 OK, 2 × 409; 11th verify in a minute → 429; reveal POST → key once, then 409 with `last4`; the revealed key verifies (S9, S10).
- `discounts-race`: cap 3, 10 concurrent `createCheckout` → 3 orders with discount, 7 `DISCOUNT_UNAVAILABLE`; expire one twice → count drops by exactly 1; a code outside its window (fake clock) is unavailable (S11).
- `checkout`: order snapshot unaffected by later product price edit; Stripe fake received `unit_amount === total_cents`, `metadata.order_id`, `payment_intent_data.metadata.order_id`, `expires_at = clock + 31 min`, idempotency key `order:<id>`; `access_token_hash` equals SHA-256 of the derived token.
- `outbox`: calls `handleStripeEvent` + `flushOutbox` directly (not through the route, so the route's own flush can't race the assertions): one receipt reaches Mailpit (`GET http://127.0.0.1:8025/api/v1/messages`) containing a library link whose token hashes to `access_token_hash` and the license key; a re-flush sends nothing; with a mailer that always throws, one pass over 1 row → `failed: 1` and `attempts = 1` (not 5); `email_outbox` holds no token or key (S8).
- `auth`: S13; S14 (middleware unit test with `NextRequest`: cookie route without/with wrong Origin → 403, correct Origin → pass, `/api/downloads` and `/api/library/license` with `Origin: null` → pass).
- `security-headers`: reads `next.config.mjs` `headers()`: the full S16 CSP including the S3 origin in `form-action` and `connect-src`, `nosniff`, the S8 headers on `/library/:path*`; every `src/lib/server/*.ts` except `env-schema.ts` starts with `import "server-only"`; the error wrapper returns a generic 500 (S8, S16).
**Browser (Playwright, Phase 5), `pnpm run test:e2e`:** `playwright.config.ts` loads `.env`, runs `globalSetup` (reset + seed `app_test` via `TSX tests/e2e/prepare-db.ts`, clear Mailpit with `DELETE /api/v1/messages`), and starts `webServer: next start -p 3002` with env `DATABASE_URL=$TEST_DATABASE_URL`, `APP_ORIGIN=http://localhost:3002`, `STRIPE_API_BASE=http://localhost:12111` (stripe-mock creates the Checkout Session). Specs sign webhooks themselves with `STRIPE_WEBHOOK_SECRET`, exactly like Gate 2. `workers: 1`.
---
## 7. Phase-by-Phase Implementation Prompts for Agents
### 7.0 Reference configuration (Phase 1 copies these verbatim)
`docker-compose.yml`. The SeaweedFS image's entrypoint already runs `weed`, so `command` starts at `server` (a leading `weed` exits with `unknown subcommand`). `-ip.bind=0.0.0.0` makes the S3 port answer on 127.0.0.1 inside the container; without it the healthcheck never passes. An HTTP healthcheck would get 403 by design (anonymous access is denied), so check the TCP port.
```yaml
services:
postgres:
image: postgres:16
environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: app, POSTGRES_DB: app }
ports: ["${PG_HOST_PORT:-5432}:5432"]
volumes: [pgdata:/var/lib/postgresql/data, ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/10-app-test.sql:ro]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U app -d app_test"], interval: 2s, timeout: 3s, retries: 30 }
seaweedfs:
image: chrislusf/seaweedfs:latest
command: ["server", "-ip.bind=0.0.0.0", "-dir=/data", "-s3", "-s3.port=8333", "-s3.config=/etc/seaweedfs/s3.json"]
ports: ["8333:8333"]
volumes: [seaweed:/data, ./seaweedfs/s3.json:/etc/seaweedfs/s3.json:ro]
healthcheck: { test: ["CMD-SHELL", "nc -z 127.0.0.1 8333"], interval: 2s, timeout: 3s, retries: 60 }
mailpit:
image: axllent/mailpit:latest
ports: ["1025:1025", "8025:8025"]
healthcheck: { test: ["CMD", "/mailpit", "readyz"], interval: 2s, timeout: 3s, retries: 30 }
stripe-mock:
image: stripe/stripe-mock:latest
ports: ["12111:12111"]
volumes: { pgdata: {}, seaweed: {} }
```
`docker/postgres/init.sql` is the single line `CREATE DATABASE app_test OWNER app;` (init scripts run only on a fresh volume; after changing it, `docker compose down -v`). The healthcheck targets `app_test`, so `--wait` also waits for it. If the Docker daemon is remote and bind mounts don't reach it, bake `init.sql` and `s3.json` into two-line Dockerfiles (`FROM … / COPY …`) and record that in `DECISIONS.md`.
`seaweedfs/s3.json`. JSON can't read env vars, so hard-code the same placeholder keys as `.env.example`; changing keys means editing both files:
```json
{"identities":[{"name":"store-app","credentials":[{"accessKey":"local-access-key","secretKey":"local-secret-key-change-me"}],"actions":["Admin","Read","Write","List"]}]}
```
`src/db/client.ts`: `postgres(url, { prepare: false, max, onnotice: () => {}, types: { int8: { to: 20, from: [20], serialize: (x: number) => String(x), parse: (x: string) => { const n = Number(x); if (!Number.isSafeInteger(n)) throw new RangeError("BIGINT outside safe-integer range"); return n; } } } })`, one pool per URL.
`src/lib/server/storage.ts`: `new S3Client({ endpoint, region, forcePathStyle: true, requestChecksumCalculation: "WHEN_REQUIRED", responseChecksumValidation: "WHEN_REQUIRED", credentials })`.
### Phase 1: Scaffold, Compose Services, Raw SQL DDL & Test Harness
```
Execute Phase 1:
1. Check `node -v` is 22.x or newer (else stop and tell the user). create-next-app refuses a directory with unknown files, so first move this plan into docs/ (an allowed name). Initialize in the current directory:
pnpm create next-app@14 . --typescript --eslint --tailwind --app --src-dir --import-alias "@/*" --use-pnpm
git init (create-next-app skips it when run non-interactively); append `.env` to .gitignore before the first commit.
Install the pinned majors from Section 1 (pnpm add … / pnpm add -D …), then: pnpm exec playwright install chromium
2. Create docker-compose.yml, docker/postgres/init.sql and seaweedfs/s3.json exactly as in Section 7.0.
3. next.config.mjs: experimental { instrumentationHook: true, serverComponentsExternalPackages: ["@node-rs/argon2", "postgres"] } and the S8/S16 headers (derive S3_ORIGIN from S3_ENDPOINT). src/instrumentation.ts per S15.
4. scripts/create-bucket.ts (idempotent CreateBucket; BucketAlreadyOwnedByYou/BucketAlreadyExists = success) using storage.ts from Section 7.0.
5. src/lib/server/env-schema.ts + env.ts (Section 1, S15), src/db/client.ts (Section 7.0), src/db/migrations/0001_init.sql (Section 2 verbatim), src/db/migrate.ts (_migrations ledger), src/db/schema.ts (Drizzle mirror).
6. scripts/seed.ts (refuses NODE_ENV=production; idempotent; exports seed(sql, storage) for e2e): seller [email protected] / demo-password-123; product 'prompt-pack' ($29.00 fixed, license enabled, limit 3 activations, 5 downloads) with a small sample PDF uploaded to the bucket (filename 'Prompt Pack – Guide v1.pdf'); product 'field-notes' (PWYW, suggested $12.00, minimum $5.00); discount code LAUNCH15 (15%, max 3 redemptions).
7. vitest.config.ts (Section 6: react-server conditions, .env loading, DATABASE_URL = TEST_DATABASE_URL, fileParallelism false, testTimeout 30 s); tests/helpers/db.ts; tests/integration/schema.test.ts (Section 6).
8. Write .env.example (Section 1) and copy it to .env; DECISIONS.md; README.md; the pnpm scripts from Section 3.
DoD: pnpm run db:up && pnpm run db:migrate && pnpm run db:seed && pnpm vitest run tests/integration/schema.test.ts && pnpm run typecheck && git check-ignore -q .env
Keep all files under 200 lines. Commit. Log [PHASE 1 COMPLETE - GATE PASSED] and immediately advance to Phase 2.
```
### Phase 2: Pure Core & Gate 1 Unit Tests (Money, Pricing, License Format, State Machine, Event Mapper)
```
Execute Phase 2 (tests first, zero I/O):
1. src/lib/money/money.ts, parse-amount.ts, pricing.ts per Section 4.5 and 4.6 (pricing computes subtotal/discount/total from a product snapshot + optional PWYW amount + optional discount row; returns a Result, never throws for user error; includes STRIPE_MIN_CHARGE_CENTS for all five currencies).
2. src/lib/licenses/key-format.ts per Section 4.8 (generateKey from an injected 25-byte source, normalize, format, last4). HMAC lives in src/lib/server/crypto.ts; derivation in src/lib/server/derive.ts (Section 1).
3. src/lib/orders/state-machine.ts: transition(order, domainEvent) -> { nextStatus, effects: ('FULFILL'|'REVOKE'|'RELEASE_DISCOUNT'|'RECORD_REFUND'|'FLAG_REVIEW')[] } implementing the Section 4.3 table exactly, including the needs_review row.
4. src/lib/stripe/event-mapper.ts: Stripe.Event -> { domain: DomainEvent, ref: { orderId, paymentIntentId } } per Section 4.2 step 4. Use JSON fixtures in tests/unit/fixtures/ shaped like real Stripe events.
5. Write every Gate 1 test listed in Section 6.
DoD: pnpm run test:gate1 && pnpm run typecheck && pnpm run lint
Commit. Log [PHASE 2 COMPLETE - GATE PASSED] and immediately advance to Phase 3.
```
### Phase 3: Storefront, Seller Dashboard & Checkout with Price Snapshots
```
Execute Phase 3:
1. Seller auth (S13, S14): src/lib/server/auth.ts, session.ts, seller.ts (requireSeller), http.ts (readCookie from req.headers, safe() generic-500 wrapper), rate-limit.ts (Postgres fixed window), client-ip.ts, api/auth/{login,logout}, (auth)/login page, src/middleware.ts (S14 with its exempt list).
2. Dashboard per Section 4.9: layout guard, sales table, /dashboard/products/new and /dashboard/products/[id] (Server Actions in dashboard/actions.ts calling dashboard-data.ts), file uploader (POST uploads/presign -> browser fetch PUT to the presigned URL -> POST uploads/finalize, Section 4.7), discount list/create/activate-deactivate.
3. Public product page /p/[slug] with buy form (method POST, action /api/checkout): email, PWYW amount input when enabled, discount code field (uppercased server-side).
4. src/lib/server/create-checkout.ts (plain module, deps-injected incl. clock): validate input with Zod, open a transaction, reserve the discount (Section 4.6), compute pricing (Phase 2), insert the pending order snapshot with access_token_hash = sha256(derived library token), then create the Stripe Checkout Session with: mode 'payment', price_data { currency, unit_amount: total_cents, product_data { name } }, quantity 1, customer_email, client_reference_id = order id, metadata.order_id, payment_intent_data.metadata.order_id, expires_at = clock + 31 min (Stripe's minimum is 30), success_url = APP_ORIGIN/checkout/success?order=<id>, cancel_url = product page, idempotencyKey 'order:<id>'. Store stripe_checkout_session_id. If the Stripe call fails, roll back (releasing the reservation).
5. api/checkout/route.ts (303 to session.url; on user error re-render the product page with the message), api/orders/[id]/status, checkout/success page that polls status every 2 s for up to 60 s, never fulfills, and on "paid" says "check your email".
6. Gate 2 tests: checkout.test.ts, discounts-race.test.ts (reservation half), auth.test.ts.
DoD: pnpm vitest run tests/integration/checkout.test.ts tests/integration/discounts-race.test.ts tests/integration/auth.test.ts && pnpm run build
Commit. Log [PHASE 3 COMPLETE - GATE PASSED] and immediately advance to Phase 4.
```
### Phase 4: Idempotent Webhook Engine, Fulfillment, Revocation, Secure Delivery & License API
```
Execute Phase 4:
1. api/webhooks/stripe/route.ts: runtime 'nodejs', rawBody = await req.text(), constructEvent with the Stripe-Signature header, reject livemode, call handleStripeEvent(event, deps), then flushOutbox best-effort. 400 on verification failure, 200 on applied/ignored/unmatched/needs_review/duplicate, 500 on thrown errors.
2. src/lib/server/handle-stripe-event.ts implementing Section 4.2 exactly (ledger insert ON CONFLICT DO NOTHING in the same transaction as side effects; order row FOR UPDATE; linkage check; pure transition; effects; outcome).
3. fulfill.ts (Section 4.4), revoke.ts (entitlement + license revoked, outbox 'access_revoked'), releaseDiscount on expiry/failure.
4. outbox.ts + scripts/flush-outbox.ts (nodemailer with the Section 1 timeouts -> Mailpit), per Section 4.4: derived library link + derived license key, skip-failed-row-within-a-pass, 5 attempts max.
5. downloads.ts + api/downloads (Section 4.7). Library page /library/[token]: file list with POST download forms, downloads remaining, revoked banner, masked license key + "Reveal license key" button (license-reveal.tsx POSTs to api/library/license, Section 4.8). Headers per S8.
6. licenses.ts + api/v1/licenses/{verify,activate,deactivate} + api/library/license (Section 4.8, S9, S10).
7. Gate 2 tests: webhook-idempotency, webhook-ordering, revocation, storage-private, downloads, licenses, discounts-race (release half), outbox, security-headers. Use tests/helpers/signed-event.ts with stripe.webhooks.generateTestHeaderString.
DoD: pnpm run test:gate2 && pnpm run build
Commit. Log [PHASE 4 COMPLETE - GATE PASSED] and immediately advance to Phase 5.
```
### Phase 5: Two-Gate Regression Harness, Browser E2E Flows & Acceptance
```
Execute Phase 5:
1. Wire `verify` = typecheck -> lint -> test:gate1 -> test:gate2 -> build -> test:e2e, fail fast. playwright.config.ts + tests/e2e/global-setup.ts per Section 6 (Browser).
2. tests/e2e/purchase.spec.ts: intercept the app's own checkout POST with page.route("**/api/checkout", r => r.fetch({ maxRedirects: 0 })), assert 303 with Location ^https://checkout.stripe.com/, and fulfill a stub page instead of following it (page.route("https://checkout.stripe.com/**") does NOT catch the redirected navigation). Flow: open /p/prompt-pack, enter email + LAUNCH15, submit; assert a pending order with total 2465; success page shows "Processing…"; POST a signed checkout.session.completed (matching session id, amount_total 2465, usd) to the webhook; success page flips to "Paid"; open the library link from the Mailpit message; assert Referrer-Policy/Cache-Control headers; click "Reveal license key" -> full key; reload -> only last4, no reveal; download through the real button (POST -> 303 -> presigned 200, suggested filename matches); downloads remaining drops by 1; verify API says valid; POST a signed charge.refunded; reload library -> "Access revoked"; download -> 403; verify -> {valid:false}.
3. tests/e2e/seller.spec.ts: log in as [email protected]; create a product via /dashboard/products/new; on its edit page upload a small PDF through the file uploader (real browser presigned PUT to SeaweedFS); the file appears in the list with its size; create a discount code on /dashboard/discounts and see it listed; collect console messages and assert none mention "Content Security Policy"; the sales table renders.
4. README: setup (Node 22, Docker, cp .env.example .env), the 4 key commands, demo login, test-mode notes, "secrets rotate = links/keys invalidate", the per-IP limit without TRUST_PROXY, bucket CORS for AWS S3/R2, and "Manual Stripe test-mode smoke test (optional, needs a Stripe test account + CLI)": put your sk_test_ key in .env, pnpm run dev, pnpm run stripe:listen (copy whsec into .env, restart), buy with test card 4242 4242 4242 4242, confirm the order turns paid and the receipt arrives in Mailpit, then refund it from the Stripe Dashboard (test mode) and confirm access is revoked. Note that `stripe trigger checkout.session.completed` events carry no order_id and are recorded as 'unmatched' by design.
5. Run the Section 8 checklist.
DoD: rm -rf node_modules .next && pnpm install --frozen-lockfile && pnpm exec playwright install chromium && pnpm run db:up && pnpm run verify
Commit. Log [PHASE 5 COMPLETE - ALL GATES PASSED]. Report the checklist, test results, and how to run pnpm run dev (with stripe listen for real test-mode payments).
```
---
## 8. Final Acceptance Checklist
- [ ] `pnpm run verify` passes from a clean clone on Node 22
- [ ] Every S-requirement has a test whose name starts with its id (`grep -rhoE "S[0-9]+:" tests | sort -uV` lists S1–S16)
- [ ] No file in `src/` exceeds 200 lines
- [ ] No `any`, `@ts-ignore`, `eslint-disable`, or `dangerouslySetInnerHTML` in `src/` (`grep -rnE ":\s*any\b|<any>|as any|@ts-ignore|eslint-disable|dangerouslySetInnerHTML" src` returns nothing; English uses of "any" in strings don't count)
- [ ] No float arithmetic on money: `grep -rnE "parseFloat|toFixed\(" src/lib/money src/lib/server` returns nothing
- [ ] The bucket rejects unsigned reads and presigned PUTs carry no checksum params (S6 tests green)
- [ ] `.env` is gitignored (`git check-ignore .env`); `.env.example` lists every variable in Section 1; `next start` with an `sk_live_` key exits 1
- [ ] No plaintext library token or license key in any table (`email_outbox` has no payload column)
- [ ] `DECISIONS.md` lists every choice made where this plan was silent (including the pinned Stripe API version and any changed ports)
- [ ] README documents setup, the demo login, the optional `stripe listen` smoke test, test cards, and the production checklist below
**Out of scope for this build (production checklist, not done here):** live-mode Stripe and account activation; sales tax / VAT / GST calculation, collection, and remittance (with Gumroad as merchant of record, Gumroad handles this; self-hosted, *you* are the merchant); fraud screening beyond Stripe defaults (Radar rules, velocity limits, card-testing protection on checkout); email deliverability (SPF/DKIM/DMARC, a real ESP, bounce handling); seller SSO/2FA and multi-seller tenancy; database backups and point-in-time recovery; object storage backups/versioning and CDN; monitoring, alerting, and webhook-failure dashboards; subscriptions/memberships; multi-currency; free/100%-off orders; affiliates; EU withdrawal-right consent for digital content; GDPR data export/deletion; abuse handling for shared license keys; license key reissue and library-link resend; a seller "grant access" action for `needs_review` orders; secret rotation without invalidating issued links and keys.
Part 3: Here Are the Technologies That Will Be Used & Why
Every tool in this stack was selected to solve a specific e-commerce and digital delivery failure mode without requiring paid third-party SaaS accounts during development:
1. Next.js 14 (App Router), Node.js 22 LTS & TypeScript 5+ (Strict Mode)
Public product pages and seller dashboards render via React Server Components and Origin-checked Server Actions, while Stripe webhook ingestion, presigned S3 redirects, and the public license verification API run on explicit Node.js route handlers. Boot-time instrumentation (src/instrumentation.ts) validates all environment secrets on startup and refuses to boot if a live Stripe key (sk_live_) is supplied in test mode.
2. PostgreSQL 16 + postgres-js with Safe int8 Parsing
All prices, discounts, subtotals, and refund amounts are stored as BIGINT integer minor units (cents) protected by database CHECK constraints. Because postgres-js returns BIGINT columns as strings by default, our database client registers a custom int8 parser that converts values into safe JavaScript integers and throws if a value ever exceeds Number.isSafeInteger.
Furthermore, neither buyer library tokens nor license keys are ever stored in plaintext: the database stores only their SHA-256 / HMAC-SHA256 hashes, while plaintext values are deterministically derived on demand from server secrets (LIBRARY_TOKEN_SECRET and LICENSE_KEY_PEPPER).
3. Stripe Checkout + stripe-mock for Zero-Account Testing
Orders capture an immutable price snapshot at checkout creation and only transition to paid when a signature-verified Stripe webhook is recorded in the stripe_events idempotency ledger. Locally, stripe-mock runs in Docker Compose so every unit, integration, and Playwright E2E test passes offline without needing a Stripe account.
4. SeaweedFS (S3-Compatible Private Object Storage) & AWS SDK v3
Digital product files never sit in public folders or database blobs. Locally, SeaweedFS provides an authenticated S3 gateway where anonymous reads return 403 Forbidden. Buyers submit a POST request that atomically increments downloads_used and redirects (303) to a 60-second presigned GetObject URL. In production, changing S3_ENDPOINT switches seamlessly to Cloudflare R2, AWS S3, or Backblaze B2.
5. Mailpit Transactional Outbox, Vitest & Playwright
Webhook transactions enqueue receipt and revocation emails into an email_outbox table using FOR UPDATE SKIP LOCKED so webhook retries never send duplicate emails. Locally, Mailpit captures all outbound emails at http://localhost:8025, while Vitest (Gates 1 & 2) and Playwright verify all 16 security requirements (S1–S16).
Part 4: Send Your Plan Through Your Agent
Because this blueprint includes an Autonomous Phased Verification Protocol and verbatim Docker Compose configurations in Section 7.0, your coding agent can build and verify all 5 phases sequentially without asking you a single follow-up question.
The Single Kickoff Prompt
Save gumroad-digital-sales-engine.plan.md into an empty folder and give your agent this single command:
claude -p "Read gumroad-digital-sales-engine.plan.md (v1.1.0). Follow the Autonomous Phased Verification Protocol: execute Phases 1 through 5 sequentially. Run and verify each phase's Definition of Done (DoD) commands before advancing. Deliver the Section 8 acceptance checklist and pnpm run verify output when Phase 5 completes."
How the 5 Autonomous Verification Gates Work
At the end of every phase, the agent must run that phase's Definition of Done commands and pass with zero errors before moving forward:
Compose Infrastructure, Raw SQL DDL & Boot Guard (Phase 1)
Starts PostgreSQL 16 (app + app_test), SeaweedFS S3, Mailpit, and stripe-mock; applies 0001_init.sql; seeds demo products; and verifies database CHECK constraints and int8 number parsing.
Pure Core Unit Suite — Money, Pricing, Keys & State Machine (Phase 2)
Runs fast, zero-I/O unit tests covering integer-cent formatting, PWYW floors, minimum Stripe charge limits across 5 currencies, Crockford Base32 key generation, and every cell of the order transition table.
Storefront, Seller Dashboard & Race-Safe Checkout Snapshots (Phase 3)
Verifies argon2id seller auth, Origin CSRF middleware, presigned S3 product uploads, immutable order price snapshots, and 10-way concurrent discount code redemption races.
Idempotent Webhooks, Private S3 Delivery & License API (Phase 4)
Tests 5-way concurrent duplicate webhook deliveries, out-of-order refunds arriving before checkout completion, 10-way download cap races, one-time POST license key reveal, and private bucket 403 enforcement.
End-to-End Playwright Browser Flows & S1–S16 Audit (Phase 5)
Runs real Chromium E2E tests for both the buyer (checkout → signed webhook → Mailpit receipt → license reveal → presigned PDF download → refund revocation) and the seller (login → product creation → browser presigned PUT upload to SeaweedFS).
"pause for review after each phase" to your kickoff prompt if you want to inspect each phase's git commit before the agent continues.
Part 5: Here Are the Commands That You Can Run to Test, Build, and Run Locally
Everything required to run and test your storefront locally is packaged inside Docker Compose—meaning you need zero cloud accounts (no AWS account, no Supabase account, and no Stripe account required for automated verification).
Prerequisites & Local Services
Make sure you have Node.js 22 LTS (`22.12+`), pnpm, and Docker Desktop (or Docker Engine with Compose) running on your machine. One command spins up all four local services:
- PostgreSQL 16 on port
5432(with isolatedappandapp_testdatabases) - SeaweedFS S3 Gateway on port
8333(private bucket with static credentials) - Mailpit on port
1025(SMTP) andhttp://localhost:8025(Web Inbox UI) - stripe-mock on port
12111(offline Stripe API server for automated tests)
Optional Real Test-Card Smoke Test: Whenever you want to test real Stripe Checkout redirects with test card 4242 4242 4242 4242, drop your sk_test_... key into .env and run pnpm run stripe:listen.
The 4 Key Commands
Open your terminal in the project folder and run these four commands to boot, verify, and explore the application:
Install Dependencies & Copy Default Environment
pnpm install && cp .env.example .env
Installs Next.js 14, Stripe SDK, AWS S3 SDK, Argon2, Vitest, and Playwright, and initializes local test secrets.
Start Containers, Apply Raw SQL Migrations & Seed Storefront
pnpm run db:up && pnpm run db:migrate && pnpm run db:seed
Starts Docker containers, initializes the private S3 bucket, applies 0001_init.sql, and seeds seller [email protected] (demo-password-123), two products (prompt-pack and field-notes), and discount code LAUNCH15.
Run the Full Verification Suite (Typecheck, Lint, Gate 1, Gate 2, Build & E2E)
pnpm run verify
Executes all unit tests, integration security tests (S1–S16), production Next.js build, and headless Chromium Playwright E2E flows.
Launch Your Local Storefront
pnpm run dev
Open http://localhost:3000/p/prompt-pack (fixed price + license key), http://localhost:3000/p/field-notes (pay-what-you-want), or http://localhost:3000/dashboard (seller admin).
Part 6: Here's How We Can Refine Things and Make It Better
Once your v1.1.0 storefront is running, you own the entire checkout, licensing, and delivery pipeline—making it easy to add high-margin features without paying platform cuts:
1. Automated Tax & VAT Collection
Enable automatic_tax: { enabled: true } on your Stripe Checkout session creation and record total_details.amount_tax into the existing orders.tax_cents column for automatic US sales tax and EU VAT calculation.
2. Product Update Broadcasts & Changelogs
When you upload a new version of a digital guide or software binary to product_files, enqueue an outbox notification to all active entitlements holders with a fresh download quota.
3. Affiliate & Referral Revenue Splits
Track referral codes on checkout sessions and trigger automated Stripe Connect transfers when orders remain paid past your refund window.
4. Tiered Product Variants & Bundles
Offer Personal vs. Team license tiers on a single product slug by associating specific product_files and license_activation_limit counts with each tier.
Part 7: Here's How to Deploy
Moving from your local Docker Compose stack to production requires just three clean infrastructure connections:
1. Managed PostgreSQL & Private S3 Bucket (Cloudflare R2 or AWS S3)
Provision a PostgreSQL 16 database (Neon, Supabase, or RDS) and a private bucket on Cloudflare R2 or AWS S3. Add a bucket CORS rule allowing PUT and GET from your APP_ORIGIN with the content-type header so seller browser uploads work seamlessly.
2. Configure Production Environment Secrets & Bootstrap Seller
Generate three 32-byte secrets using openssl rand -base64 32 for LICENSE_KEY_PEPPER, LIBRARY_TOKEN_SECRET, and IP_HASH_SECRET. Run migrations and create your seller account:
pnpm run db:migrate
pnpm run admin:create
3. Connect Stripe Webhook Endpoint & Outbox Cron
In the Stripe Dashboard, register https://store.yourdomain.com/api/webhooks/stripe for checkout.session.*, payment_intent.succeeded, charge.refunded, and charge.dispute.*, and schedule a 1-minute cron running pnpm run outbox:flush to retry any transient SMTP failures.
Part 8: Let PromptScale Help You with the Deployment
"Let PromptScale help you with the deployment. In one week we can have your project vetted, secure, and hosted with the domain of your choice."
When real money, Stripe webhooks, and private digital deliverables are on the line, production hardening matters. If you want our senior engineers to configure your live Stripe webhook cutover, Cloudflare R2 private bucket policies, SPF/DKIM transactional email deliverability, and automated database backups, our team is ready to step in.
Explore More Agent Blueprints
View All →Availability & Booking Engine →
PostgreSQL GIST exclusion locks & multi-timezone slot math.
Replaces Harvest & TogglTime Tracker & Invoicing Engine →
Clock drift recovery & sequential invoice locks.
Replaces Typeform & TallyConversational Form Engine →
Keyboard navigation & JSON DAG logic branching.