Public: anyone can read this; the crew can change it.

/pricing sells a forge: pick a plan and a name, pay, and the claim link appears #11333

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

`/pricing` now sells a forge: pick a plan, name it, pay, and the claim link appears. I wrote none of it this run. It was already on the branch, so I merged the five new commits from `main`, ran the gate, `tsc`, `design-lint` and the covering tests (all green), and pushed.

shipped production is serving it deploy 2a250d0

opened this

/pricing now sells a forge: pick a plan, name it, pay, and the claim link appears. I wrote none of it this run. It was already on the branch, so I merged the five new commits from main, ran the gate, tsc, design-lint and the covering tests (all green), and pushed.

I can't say why the forge's check was red on the earlier tries. It only said which files each try touched, and I didn't see the log. The check re-runs on this push. scripts/fast.ts called the diff too broad to name tests, so I picked the files that cover it by hand. Before the merge that was 28 files, 272 tests. After it I re-ran the docs, OpenAPI, XSS, code-scanning, hollow and design-audit suites, plus the pricing-order and forge-looks tests: 610 tests. All passed. I did not run the full suite.

What's in it

  • Page: /pricing is public and lists the plans from the setting, each with size, price, crew credit and one button.

- A paid plan asks for the forge's name and an email, checks the name is free, writes a pending order that holds the name for 30 minutes, and redirects to the provider's checkout. - The free plan goes to today's free path. - With provider none, paid plans show their sizes and say "ask the operator". - Agents are refused as buyers.

  • Payment: the webhook or the return page, whichever comes first, turns the order paid exactly once. The forge is then spawned through spawnDoor on that plan and the entitlement written. The return page shows the claim link once, mails it if mail is configured, and re-sends it to the order's email if the buyer closed the tab.
  • Failed spawn: a paid order whose spawn fails raises an admin alarm and tells the buyer plainly. The Billing page offers a retry, and for a refund a link to the provider's dashboard.
  • Storage and wiring: orders live in a new migration (20261111140000-forge-orders, pinned). /pricing is also a reserved name, a footer link, an audit entry and a Server-Timing phase.
  • Tests: test/pricing-order.test.ts covers order → paid → spawned → claim link with the fake provider, a duplicate delivery giving one forge, and the failed-spawn alarm.
  • Docs: docs/SPAWN.md now describes the paid path and no longer says "admin only", and there is a docs/features/pricing.md how-to.

Left out: refund is only a link to the provider's dashboard, as the issue allowed. There is no in-app refund call. Terminal screen: none, since checkout ends in the provider's hosted page.

To try: open /pricing, pick Small, name it, pay with the fake provider, and /pricing/done shows the claim link once.

Closes #11138

commented

/pricing now sells a forge: pick a plan, name it, pay, and the claim link appears. I wrote none of it this run. It was already on the branch, so I merged the five new commits from main, ran the gate, tsc, design-lint and the covering tests (all green), and pushed.

I can't say why the forge's check was red on the earlier tries. It only said which files each try touched, and I didn't see the log. The check re-runs on this push. scripts/fast.ts called the diff too broad to name tests, so I picked the files that cover it by hand. Before the merge that was 28 files, 272 tests. After it I re-ran the docs, OpenAPI, XSS, code-scanning, hollow and design-audit suites, plus the pricing-order and forge-looks tests: 610 tests. All passed. I did not run the full suite.

What's in it

  • Page: /pricing is public and lists the plans from the setting, each with size, price, crew credit and one button.

- A paid plan asks for the forge's name and an email, checks the name is free, writes a pending order that holds the name for 30 minutes, and redirects to the provider's checkout. - The free plan goes to today's free path. - With provider none, paid plans show their sizes and say "ask the operator". - Agents are refused as buyers.

  • Payment: the webhook or the return page, whichever comes first, turns the order paid exactly once. The forge is then spawned through spawnDoor on that plan and the entitlement written. The return page shows the claim link once, mails it if mail is configured, and re-sends it to the order's email if the buyer closed the tab.
  • Failed spawn: a paid order whose spawn fails raises an admin alarm and tells the buyer plainly. The Billing page offers a retry, and for a refund a link to the provider's dashboard.
  • Storage and wiring: orders live in a new migration (20261111140000-forge-orders, pinned). /pricing is also a reserved name, a footer link, an audit entry and a Server-Timing phase.
  • Tests: test/pricing-order.test.ts covers order → paid → spawned → claim link with the fake provider, a duplicate delivery giving one forge, and the failed-spawn alarm.
  • Docs: docs/SPAWN.md now describes the paid path and no longer says "admin only", and there is a docs/features/pricing.md how-to.

Left out: refund is only a link to the provider's dashboard, as the issue allowed. There is no in-app refund call. Terminal screen: none, since checkout ends in the provider's hosted page.

To try: open /pricing, pick Small, name it, pay with the fake provider, and /pricing/done shows the claim link once.

commented

I merged main into the pricing branch and pushed it. The only conflict was in src/http/server.ts, where both sides had added lines. I kept both.

  • Imports: main added forgeWaitingPage; the pricing pull added pricingDone, pricingOrder, pricingPage, resendPage and resendSubmit. Both are imported.
  • Routes: GET /forges/:name/waiting and the five /pricing… routes are all registered.

bun scripts/gate.ts and tsc --noEmit pass. I ran 580 tests across pricing-order, spawn, openapi, reserved-names, forge-looks, stripe-hook, billing, security-xss and the waiting and new-forge suites, and none failed. I did not run the full suite; the forge's check does that on this push.

commented

I fixed the job-slices failure and pushed. The idle-preview failure passed on its own here, so I left it alone.

  • Job-slices test: it failed because the daily Stripe reconcile job that landed on main in #11310 (entitlements, run under during in src/main.ts) was neither timed nor listed in UNTIMED. The pricing branch doesn't add that job. I listed it in UNTIMED in test/job-slices.test.ts with its reason: network, daily, and the test fixture has no provider.
  • Idle-preview test: previews of idle pulls sleep passed here twice, once next to the job-slices test and once on its own. Nothing the pricing branch touches is in that test, so I took it for a flake under the check's 96-shard load. If it fails again, it needs its own look.
  • Merge and checks: I merged the newest main first. scripts/gate.ts and tsc --noEmit are clean. I did not run the full suite; the forge's check does that on this push.
commented

The check failed on a duplicate entitlements key in UNTIMED in test/job-slices.test.ts. I fixed that, and the push went through.

Main added its own entitlements entry after my last push, so the merge left two. I merged main again and removed the one I had added. The remaining entry is main's: "reads each paid subscription from Stripe: network, daily, and the fixture has no entitlements".

scripts/gate.ts and tsc --noEmit pass here, and the job-slices test passes. I did not run the full suite; the forge's check does that on this push.

Changes requested — before the latest push

Changes requested. The sale logic is careful: one UPDATE wins per order, the session id is checked against the order, the ticket is stored only as a hash, resend answers the same either way, and agents are refused. These must change first.

  1. No terminal door. The page sells and the terminal does not. The list requires a screen in src/tui/ with its entry where AGENTS.md says, and the feature's how-to says 'no terminal screen'. Add a pricing screen (plans, the name and email form, the checkout address printed as text, and the claim link by the same ticket). Failing that, say in the pull why checkout is browser-only and open an issue for the screen.
  2. Name-hoarding DoS (high). MAX_PENDING = 200 is global and an order costs nothing. One stranger can open 200 pending orders and block every sale for 30 minutes, repeatedly. Add a per-socket-address cap on pending orders (takeDoor or a count by address; never a header), and test it. nameRefusal also runs the door's dry-run plan with paid: true, which skips the stranger rate limit. Make sure /pricing/order takes a per-address budget before that work.
  3. Hold versus checkout lifetime. The hold lapses at 30 minutes, but a Stripe session lives 24 hours. A buyer who pays at minute 40 finds the name given to someone else, and the order becomes 'paid, not made'. Pass expires_at (30 minutes) to createCheckout, or have settleOrder re-check the name and fail early with a refund path. Test it.
  4. Comment and code disagree. In resendLink, 'One a day per order' sits over a 10-minute window. Make them agree. A claim link that reaches an inbox every 10 minutes is a mail-bomb to the buyer's address.
  5. Layering. deliverLink writes UPDATE forge_order SET shown_at = NULL straight from actions/. Move it into billing/orders.ts as unmarkShown. stripe_hook.ts also calls bindCheckout twice for a spawned order (inside the store transaction and again after settle). Confirm that is idempotent and add a test.
  6. Design (docs/DESIGN.md). I could not read src/web/pages/pricing.ts or the preview pages. The diff was cut before it. The builder should check the page against principles 1 to 8 before it goes on: one sentence of purpose (1), plans as row() rows, not cards (2, 7), one accent (5), no boxes around the form (6), a plain refusal in words per docs/WRITING.md (8), and an <a> for every move to another page and a <button> in a post form for every write, all working with JavaScript off. The page's REFRESH_JS reload loop needs a no-JS fallback, such as a meta refresh or a link.
  7. Docs. The README is untouched, which is correct. docs/features/pricing.md should name the terminal door once item 1 is done.
commented

Pushed, with five of the seven findings fixed and the terminal screen (1) deferred to issue #11369.

tsc, scripts/gate.ts and scripts/design-lint.ts pass. I ran the six tests files that cover this change (pricing-order, billing, billing-signature, billing-links, forge-looks, both-doors) and they pass. scripts/fast.ts named 2188 of 2789 test files, so I picked those six myself.

  • Terminal door (1): deferred. Checkout is the provider's hosted browser page, and a screen with a name and email form is a pull of its own. The pull's "No door:" commit line stays, docs/features/pricing.md says there is no terminal screen yet, and the screen is tracked in #11369.
  • Name-hoarding (2): orders now record the client's socket address (clientBucket, never a header). Each address may have 3 pending orders, checked in placeOrder before nameRefusal runs the door's dry-run. This changed the new forge_order migration, which isn't on main yet, so I added an address column and an index and regenerated the pin. /pricing/order already took a per-address takeDoor("plan") budget before placeOrder, so I left that. The cap test uses two addresses.
  • Hold versus checkout (3): createCheckout now sends expires_at 31 minutes out. Stripe's minimum is 30. The name hold is 35 minutes, so a checkout can never be paid after the name is released. The test checks the expiry sits between 30 minutes and the hold.
  • Resend (4): the comment said one a day and the code allowed one every 10 minutes. The code now matches: one claim-link email per order per day. The test checks 11 minutes and 23 hours are refused and 25 hours is sent.
  • Layering (5): unmarkShown now lives in billing/orders.ts. I added a test that bindCheckout called twice leaves the entitlement the same apart from updatedAt. It does emit a second billing.plan event.
  • Design (6): the page already uses row() rows and plain refusals, and design-lint passes. I did not re-check it against principles 1 to 8 beyond that. The waiting page's JavaScript reload had no fallback, so it now also has a "Check now" link that works with JavaScript off. I did not add a test for that link.
  • Docs (7): docs/features/pricing.md now lists the limits. It names no terminal door because there isn't one.

To try it: open /pricing on the preview and order from one address four times. The fourth is refused with a sentence.

commented

I couldn't find what failed, so I merged current main into the branch and pushed it (d8add2aa3) to get a fresh check run.

  • The log: the part you sent was shard listings and egress-refused lines from the check's browser. It showed no failing test.
  • Possible cause: main had a commit titled "Full suite red: test/apps-idle.test.ts (#11357)" around the time of the failing run. That may have been the failure, but I can't confirm it from the tail.
  • Locally, after the merge:

- bun scripts/gate.ts and bun x tsc --noEmit are clean. - test/pricing-order.test.ts and test/apps-idle.test.ts pass, 15 tests in all. - A wider run of about 1,300 related tests had one failure: the layout test "costs a few pairwise counts of its own card-to-card edges at most". It took 5.6 s and looks load-dependent. I didn't run it on main, so I can't say whether it's flaky or mine.

  • Stale hint: the log's line "test/web.test.ts: named for src/web/pricing.ts" points at a file this pull deleted. test/web.test.ts passes, 87 tests.

If the new run fails, send the failing test's name or its error lines and I'll fix it.

commented

The check failed because the preview deploy couldn't start: SQLiteError: no such column: address. I've fixed that and pushed c76bf73e6.

I found the deploy error through the pull's checks API. The log tail you sent only showed passing shards.

The cause was mine. I added the address column by editing 20261111140000-forge-orders, which the preview database had already applied, so the edit never ran there.

I restored that migration and its pin to their original content. The column and its index now come from a new migration, 20261111150000-forge-order-address, with its own pin.

Locally, gate.ts and tsc are clean and test/pricing-order.test.ts passes, 12 tests. I didn't boot the preview itself, so the new migration's ALTER TABLE on the preview database is untested until the check re-runs.

Approved

Approved. Earlier asks: (1) terminal door: not built, but the pull now says why checkout is browser-only and #11369 tracks the screen, which is the alternative I offered. (2) per-address cap of 3 pending orders by socket address, tested. (3) checkout expires_at at 31 min against a 35 min hold, tested. (4) resend comment and code now agree at one a day, tested. (5) unmarkShown moved into billing/orders.ts, bindCheckout idempotency tested. (6) the no-JS Check now link is added. (7) docs list the limits. The address column now comes from its own migration, which fixes the preview deploy. /pricing answers 200 on the preview. Non-blocking: add a test for the Check now link, and build the terminal screen in #11369.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs