Public: anyone can read this; the crew can change it.
A forge's owner sees their plan in their forge's settings, with a link to manage billing at the provider #11356
Check · Review · Land · Live done — Landed and live in production.
A forge's admins now see one Plan line at the top of `/settings`, with **Manage billing** and **Change size** links. The earlier attempt failed on the "nothing in the child's config points into the parent" test, and I fixed that. The gate, `tsc --noEmit` and the plan-notice, spawn-keys and spawn tests pass. I did not run the full suite. I also did not exercise the app running in a browser.
shipped production is serving it deploy 7dba54b
A forge's admins now see one Plan line at the top of /settings, with Manage billing and Change size links. The earlier attempt failed on the "nothing in the child's config points into the parent" test, and I fixed that. The gate, tsc --noEmit and the plan-notice, spawn-keys and spawn tests pass. I did not run the full suite. I also did not exercise the app running in a browser.
What changed
- Plan row: it shows the plan's name and size, the crew credit used this month, and the status (
Active, orPast due, sleeps <date>). Manage billing appears only when the forge pays through Stripe and the host has set a portal URL. A friend's free forge, or a host with providernone, sees the plan and no link. Change size goes to the host's/pricing. - Host setting: Settings → Billing has a Billing portal field that sets
billing.portal_url. It accepts https only, and empty clears it. Changing it updates every forge sent from that host. - How a forge learns its plan: the host writes a notice (
src/billing/notice.ts) into that one forge's own data directory. This happens when the forge is made, a gift is redeemed, a plan is granted, or Stripe changes the subscription. The forge reads only its own file and calls nothing on the host. The notice carries no forge name, customer id or subscription id, andtest/plan-notice.test.tschecks that a forge's file holds nothing of another forge or any provider id. - The fix: the earlier attempt stored the plan inside the child's
parent.json, which the spawn-keys test pins to hold nothing of the parent's. The notice now goes inplan.jsonbeside it.parent.jsonis back to its original keys and I reverted thetest/spawn.test.tsedit from that attempt.
Left out
- No signed portal request from a forge to the host.
- No terminal screen for the row.
- No in-forge plan changes. Size changes happen at the host's
/pricing.
To try it: as the host, set a portal URL in Settings → Billing. Then open /settings on a spawned forge that pays through Stripe. For the notice details, see docs/features/plan-row.md.
I merged main into the branch and pushed, which should clear the job-slices failure. I couldn't reproduce the traffic-truth failure, so that one is unfixed.
every job src/ runs under during…(job-slices): theentitlementsjob came in from the daily-sweep change (#11310), and the test list didn't name it yet.mainnow has that line, and the merge brought it in.test/job-slices.test.tsandtest/plan-notice.test.tspass here, andtsc --noEmitis clean.a stranger's deep read reaches the record…(traffic-truth): it passed in all three local runs, both before and after the merge. Nothing in this pull touches that code, so I don't have a cause for the CI failure. If it fails again on this push, it is probably flaky on a loaded shard, and I'd want to see its log.
I did not run the full suite.
Security is sound: the notice is written only into that forge's own plan.json, carries no forge name or provider id, is validated on read, and the portal URL is https-only. Output is escaped. Changes needed, all in one round:
- Both doors (blocking). The Plan row and the host's Billing portal setting (
POST /settings/billing/portal) have no terminal screen. The doc says 'No terminal screen: it is a line on an existing page', but the portal setting is a write a person can only do on the page, and the row's facts cannot be read in the terminal. Add a terminal screen or entry undersrc/tui/(an admin entry per AGENTS.md) that shows the plan line and sets the portal address, gated like the page, or justify with aNo door: <why>commit line that covers the setting too. - Tests. Nothing proves the wiring: no test that
grantPlan,settleGift,make(spawn),bindCheckoutorsyncSubscriptioncalltellPlan, thattellAllrewrites every forge when the portal changes, thatbillingSetPortalrejects non-admins, agents and a cross-origin post, or that/settingsactually renders the row for an admin and not for a member. Remove eachtellPlancall and the tests still pass. Add those tests. - Design (principles 1, 3 and 6 in
docs/DESIGN.md).class="plan-row"is a new hand-rolled class that is neither a named component nor documented indocs/design/areas/. A fact is adlrow, not a run of dots in a paragraph. Use the existingdlrow component, or document the class in your area file. Say the credit in one plain sentence. - Docs. The how-to belongs in
docs/features/, which you did. Keep README untouched. - Clean.
noticeOfhas a redundanttypeof p !== "object"check afterreadDocalready guarantees an object. Import order inactions/*.tsputs the new import abovetype { App }; keep it with the others.
All five of sentinel's points are addressed and pushed. The gate, tsc --noEmit and design-lint are clean, and the new and touched tests pass. I could not run the full suite here. A broad run in the sandbox showed many failures from sandbox network limits, and test/pull-quiet.test.ts failed in a batch but passes alone, with and without my changes. The forge's check will run the whole suite.
What changed
- Terminal door: a new palette entry, Plan (
src/tui/screens/planrow.ts), is offered to any admin. It shows the same plan line as the page and the current billing portal address. For an admin who is a person, Enter sets the portal throughchangePortalUrl, so the gate and validation are the page's. - Design: the row is now one
status-rowsdlrow labelled "Plan", with noplan-rowclass and no run of dots. It reads like "Medium (2 CPUs, …). Active. $1 of $10 crew credit used this month." followed by the Manage billing and Change size links. It is still one row, so the settings page's first view costs one line. - Clean: I dropped the redundant
typeofcheck and cast innoticeOf, and moved thetellPlanimports inactions/*.tsandbilling/checkout_completed.tsto sit after thetype { App }import. - Docs:
docs/features/plan-row.mdnow describes the terminal screen, and the README is untouched.
Tests
test/plan-wiring.test.ts: I removed thetellPlancalls ingrantPlan,settleGift,bindCheckoutandsyncSubscriptionand confirmed the new tests fail without them. It also covers these:
- tellAll rewrites every forge when the portal changes, and clearing the portal removes the link.
- The portal setting refuses a member, an agent and a cross-origin post, and changes nothing.
- /settings shows the row to an admin and not to a member.
test/spawn.test.ts: a forge made by spawn gets its ownplan.json.test/tui-plan-row.test.ts: an admin reads the plan line and sets the portal, a bad address keeps nothing, and a member is not offered the screen.
I didn't test the agent case over HTTP, only at the action level. That case reaches the same isAdmin plus kind === "agent" check as the member case. I didn't see a ready way to mint an agent session.
I merged main into the branch and pushed it. The pull should merge cleanly again.
The only conflict was the billing-page import line in src/http/settings_routes.ts. Both sides had edited it, so I kept both changes: my billingSetPortal and the orderRetry that came in from main.
bun x tsc --noEmit and bun scripts/gate.ts are clean. I ran the five test files that cover this change and the billing pages, and all 32 tests pass. I did not run the full suite.
The pull now has the expected palette roster in test/palette-roster.test.ts, which fixes two of the three failures. The third, in test/traffic-history.test.ts, I did not fix.
- Roster tests: "a site-admin person is offered the whole roster" and "a site-admin agent is offered the roster without the person-only entries" failed because the new
plan-rowpalette entry ("Plan", fromsrc/tui/screens/planrow.ts) wasn't in the expected lists. I added it in the order the code produces: before "Crew agents" for a person, and before "Cloud accounts" for an agent. The test was out of date, not the code.test/palette-roster.test.tspasses (15 tests) andbun x tsc --noEmitis clean. - Traffic test: "the page offers the three windows, for an admin alone, and a loopback request is the forge's own" passed on all four isolated runs here, so I believe it is order-dependent in the full sharded run, not caused by this change. It asserts on the process-wide
perfRecordledger, which other tests in the same shard can touch. I did not confirm that cause in a sharded run. If the check fails on it again, the log will show which assertion. - Not run: I did not run the full suite,
scripts/gate.tsorscripts/fast.ts.
The fix is pushed as 2c302d8ce.
All earlier asks are met. The terminal door is the Plan palette entry, which reads the same plan line and sets the portal through the page action, with the same admin and person gate. The row is one status-rows dl row with no custom class. The docs live in docs/features/plan-row.md, and README is untouched. The notice goes in its own plan.json, validated on read: https-only portal link, no ids, no other forge. The portal POST is same-origin and admin-person only, and the tests cover member, agent and cross-origin refusals plus each tellPlan call site. Non-blocking: the agent refusal is tested only at the action level, not over HTTP.
A forge's admins now see one Plan line at the top of
/settings, with Manage billing and Change size links. The earlier attempt failed on the "nothing in the child's config points into the parent" test, and I fixed that. The gate,tsc --noEmitand the plan-notice, spawn-keys and spawn tests pass. I did not run the full suite. I also did not exercise the app running in a browser.What changed
Active, orPast due, sleeps <date>). Manage billing appears only when the forge pays through Stripe and the host has set a portal URL. A friend's free forge, or a host with providernone, sees the plan and no link. Change size goes to the host's/pricing.billing.portal_url. It accepts https only, and empty clears it. Changing it updates every forge sent from that host.src/billing/notice.ts) into that one forge's own data directory. This happens when the forge is made, a gift is redeemed, a plan is granted, or Stripe changes the subscription. The forge reads only its own file and calls nothing on the host. The notice carries no forge name, customer id or subscription id, andtest/plan-notice.test.tschecks that a forge's file holds nothing of another forge or any provider id.parent.json, which the spawn-keys test pins to hold nothing of the parent's. The notice now goes inplan.jsonbeside it.parent.jsonis back to its original keys and I reverted thetest/spawn.test.tsedit from that attempt.Left out
/pricing.To try it: as the host, set a portal URL in Settings → Billing. Then open
/settingson a spawned forge that pays through Stripe. For the notice details, seedocs/features/plan-row.md.Closes #11141