Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Testing Floodwall

Three parts, each tested on its own, then together on the preview site.

| Part | Where | What it is |
|---|---|---|
| Crate | `src/`, `tests/` | The Rust library: admission, scheduler, gate, hold queue, ledger |
| Sign-in and billing service | `api/` | Cloudflare Worker at `api.floodwall.ai`, D1 `floodwall-auth`, Paygate for payments |
| Site | `site/` → `docs/` | React + Vite: floodwall.ai (from `main`), preview.floodwall.ai (from `preview`) |

Run one test command at a time.

## 1. Crate

```sh
cargo test
cargo clippy --all-targets -- -D warnings
cargo run --example production # the OPA/Rego-style guardrails at the gate
node --test tools/verify-ledger.test.mjs # the independent ledger verifier
```

## 2. Service, locally

```sh
cd api
npm ci
npm run check # typecheck, bundle, Miniflare tests (Resend and Paygate stood in)
cp .dev.vars.example .dev.vars # Resend key, AUTH_SECRET, your address in ADMIN_EMAILS, Paygate test key
npm run migrate:local
npm run dev # http://127.0.0.1:8787
```

And the site against it, in another terminal:

```sh
echo VITE_AUTH_API_URL=http://127.0.0.1:8787 > site/.env.development.local
npm --prefix site run dev # open http://127.0.0.1:5173 (not localhost: the cookie is SameSite=Strict)
```

Without `VITE_AUTH_API_URL` the site runs an in-browser demo: the code is shown on the page and
billing says it needs the service.

### Billing end to end, locally

Paygate has to reach the local Worker for webhooks:

```sh
cloudflared tunnel --url http://127.0.0.1:8787
```

In Paygate (test mode), set the floodwall app's webhook URL to `<tunnel>/billing/webhook` and
send a test webhook. Then:

1. Sign in, open `/account`, choose **Move to Team**.
2. Pay with `4242 4242 4242 4242`, any future date, any CVC.
3. Back on `/account?billing=success`: it says the plan is active and shows **Team** with its
renewal date. In D1: `npx wrangler d1 execute floodwall-auth --local --command "SELECT email, plan, plan_status FROM users"`.
4. **Invoices, card and cancelling** opens Paygate's billing page. Cancel: the account shows
**Ends <date>** and returns to Free when the period ends (advance a test clock on Paygate's
**Testing** page to see it).
5. A declined card (`4000 0000 0000 0002`) sends `payment.failed`: the owner gets an email.
6. Paygate's **Webhooks to apps** shows every delivery and lets you resend one; a resend is
answered `duplicate: true` and changes nothing.

## 3. Site

```sh
npm --prefix site test
npm --prefix site run build # writes docs/; add `-- --outDir <dir>` to build elsewhere
```

## 4. Live, on preview.floodwall.ai

Pushing to `preview` deploys the site (`.github/workflows/preview.yml`). The Worker deploys by hand:

```sh
cd api && npm run migrate:remote && npm run deploy
npx wrangler tail floodwall-api # live logs while you test
curl -i https://api.floodwall.ai/auth/session # 200 { "user": null }
```

Smoke test, in a private window:

1. `/signup` with an address you can read → six-digit code by email → `/waitlist`.
2. As an admin (`ADMIN_EMAILS`), `/app/admin` → **Approve** → the "You're in" email → `/app`.
3. `/account` → **Plan and billing** shows **Free**.
4. `/pricing` signed in: Free is marked **Your plan**; **Move to Team** opens Stripe Checkout.
5. Pay with the 4242 test card (the service holds a `pg_sk_test_…` key, so nothing is charged),
come back to `/account`, see **Team**.
6. Open the billing page, cancel, check the account shows the end date.
7. Sign out; `/account` sends you to `/login`.

Going live is swapping the Worker's Paygate key and webhook secret for live ones
(`npx wrangler secret put …`), after the Terms, Privacy and Refund pages are up (FW-605).
7 changes: 7 additions & 0 deletions api/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ ADMIN_EMAILS=you@example.com

# Uncomment to let anyone in, as after the beta.
# SIGNUP_MODE=open

# Billing, through Paygate's test mode (pay.erpai.studio → Apps → floodwall → Test).
# Without the key, /billing answers 503 and the account page says billing is unavailable.
PAYGATE_SECRET_KEY=pg_sk_test_...
# "Show signing secret" on the app page. Point its test webhook URL at a tunnel to
# this service, e.g. `cloudflared tunnel --url http://127.0.0.1:8787` + /billing/webhook.
PAYGATE_WEBHOOK_SECRET=
52 changes: 50 additions & 2 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ which the site shows as it is.
| `POST /admin/approve` | `{ "email": "..." }` | `200 { "approved": true, "emailed": true \| false }`; `404` no such account; `401`, `403` as above |
| `POST /admin/remove` | `{ "email": "..." }` | `200 { "removed": true }`; `400` an admin; `404` no such account; `401`, `403` as above |

| `GET /billing` | | `200 <plan>`; `?refresh=1` asks Paygate first; `401` signed out; `403` waiting |
| `POST /billing/checkout` | `{ "plan": "team" \| "business", "interval": "month" \| "year" }` | `200 { "url": "<Stripe Checkout>" }`; `400` unknown plan; `409` already on a plan; `502` Paygate failed; `503` no key |
| `POST /billing/manage` | `{}` | `200 { "url": "<Paygate billing page>" }`; `404` never checked out |
| `POST /billing/webhook` | Paygate's event, signed | `200 { "received": true }`; `400` bad or stale signature. Called by Paygate, not the site: no origin check, no session |

`<plan>` is `{ "plan": "free" | "team" | "business", "status", "features", "periodEnd", "cancelAtPeriodEnd" }`.

`<user>` is `{ "email": "...", "waiting": false, "admin": false }`. `waiting` is
true while the account is on the waitlist; the site then shows `/waitlist`
instead of the console. Anything added to the service later that only
Expand All @@ -110,6 +117,45 @@ approved accounts may use should check `waiting` the same way.
Without `RESEND_API_KEY`, or an `AUTH_SECRET` of at least 32 characters,
`/auth/code` and `/auth/verify` answer `503`.

## Billing

Plans are sold through [Paygate](https://pay.erpai.studio), our Stripe hub
(`~/code/v2/payment-gateway`). The Worker never sees a card or a Stripe key:

1. **Upgrade.** `POST /billing/checkout` asks Paygate for a Stripe Checkout
page for the account (`external_id` = the account's id) and the site goes
there. Stripe returns to `/account?billing=success`, or `/pricing` if
cancelled.
2. **Paid.** Paygate sends a signed `entitlement.changed` webhook to
`POST /billing/webhook`. The Worker checks the signature
(HMAC-SHA256 with `PAYGATE_WEBHOOK_SECRET`, 5-minute tolerance), skips an
event id it has seen, and copies the plan, status, limits (`features`) and
period end onto the account. An older event never overwrites a newer one.
`payment.failed` emails the owner. Coming back from checkout, the account
page also asks for `?refresh=1`, in case the webhook is still on its way.
3. **Manage.** `POST /billing/manage` opens Paygate's hosted billing page:
invoices, card, cancel and resume.

Every account starts on `free`. Plan changes between Team and Business, and
usage-based overage (metered `decisions`, FW-704), come later.

**Paygate setup** (dashboard → Apps → `floodwall`, test mode first): plans
`team` ($49/mo, $490/yr) and `business` ($399/mo, $3,990/yr) with these
features JSON, success and cancel URLs as above, webhook URL
`https://api.floodwall.ai/billing/webhook`, and allowed origins
`https://floodwall.ai`, `https://preview.floodwall.ai`.

```json
{ "planes": 5, "decisions_included": 250000, "retention_days": 90, "approvers": null, "slack": true, "sso": false }
{ "planes": null, "decisions_included": 2500000, "retention_days": 365, "approvers": null, "slack": true, "sso": true, "sla": "99.9" }
```

**Secrets:** `npx wrangler secret put PAYGATE_SECRET_KEY` and
`npx wrangler secret put PAYGATE_WEBHOOK_SECRET`. A `pg_sk_test_…` key keeps
everything in Stripe test mode; going live is swapping in the live key and
live webhook secret, no code change. Run `npm run migrate:remote` for
`0003_billing.sql` before deploying.

## Local development

```sh
Expand Down Expand Up @@ -142,10 +188,12 @@ npm run check # typecheck, bundle, then the tests

The tests run the bundle that `wrangler deploy` would upload in the Workers
runtime (Miniflare), with an empty D1 database and a stand-in for Resend that
keeps every email. They cover the whole flow and each rule above, including
keeps every email. A stand-in for Paygate answers the billing calls. They cover the whole flow
and each rule above, including
requests that race each other, a Resend that refuses, a missing secret, the
cookie's attributes, origin checks, the daily sweep, the waitlist, the admin
calls and both settings of `SIGNUP_MODE`. CI runs them on every pull request.
calls, both settings of `SIGNUP_MODE`, and billing: checkout, the billing
page, signed and replayed webhooks, and webhooks out of order. CI runs them on every pull request.

## Deploying

Expand Down
23 changes: 23 additions & 0 deletions api/migrations/0003_billing.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Billing through Paygate (pay.erpai.studio), which runs Stripe for us. An
-- account's plan is copied here from Paygate's entitlement.changed webhook,
-- so pages can read it without calling Paygate. Every account starts on Free.

ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free';
-- Paygate's subscription status: active, trialing, past_due, canceled, or null on Free.
ALTER TABLE users ADD COLUMN plan_status TEXT;
-- The plan's limits as JSON (planes, decisions_included, retention_days, ...).
ALTER TABLE users ADD COLUMN plan_features TEXT;
ALTER TABLE users ADD COLUMN plan_period_end INTEGER;
ALTER TABLE users ADD COLUMN plan_cancel_at_period_end INTEGER NOT NULL DEFAULT 0;
-- When the state copied here was true. Webhooks can arrive out of order, so
-- an older one never overwrites a newer one.
ALTER TABLE users ADD COLUMN plan_updated_at INTEGER NOT NULL DEFAULT 0;

-- Webhooks already handled, by Paygate's event id: delivery is at least once.
CREATE TABLE billing_events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
received_at INTEGER NOT NULL
);

CREATE INDEX billing_events_received ON billing_events (received_at);
10 changes: 9 additions & 1 deletion api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ export async function verifyCode(request: Request, env: Env, now: number) {

/** The account the session cookie belongs to, or null. */
export async function sessionUser(request: Request, env: Env, now: number) {
const account = await sessionAccount(request, env, now);
return account && account.user;
}

/** The session's account with its id, for calls that need to name it to another service. */
export async function sessionAccount(request: Request, env: Env, now: number) {
const token = readSessionCookie(request);
if (!token || !isToken(token)) return null;
const row = await env.DB.prepare(
Expand All @@ -205,7 +211,7 @@ export async function sessionUser(request: Request, env: Env, now: number) {
)
.bind(await tokenHash(token), now)
.first<UserRow>();
return row ? publicUser(row, env) : null;
return row ? { id: row.id, user: publicUser(row, env) } : null;
}

export async function currentSession(request: Request, env: Env, now: number) {
Expand All @@ -227,5 +233,7 @@ export async function sweep(db: D1Database, now: number) {
db.prepare("DELETE FROM sessions WHERE expires_at <= ?").bind(now),
// The longest window is a day, so one that started a day ago has ended.
db.prepare("DELETE FROM rate_limits WHERE window_start <= ?").bind(now - DAY),
// Paygate stops retrying a webhook after 3 days, so a week covers any repeat.
db.prepare("DELETE FROM billing_events WHERE received_at <= ?").bind(now - 7 * DAY),
]);
}
Loading
Loading