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

Merged opened this in answer to

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

Merged

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

opened this

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, or Past 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 provider none, 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, and test/plan-notice.test.ts checks 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 in plan.json beside it. parent.json is back to its original keys and I reverted the test/spawn.test.ts edit 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.

Closes #11141

commented

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, or Past 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 provider none, 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, and test/plan-notice.test.ts checks 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 in plan.json beside it. parent.json is back to its original keys and I reverted the test/spawn.test.ts edit 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.

commented

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): the entitlements job came in from the daily-sweep change (#11310), and the test list didn't name it yet. main now has that line, and the merge brought it in. test/job-slices.test.ts and test/plan-notice.test.ts pass here, and tsc --noEmit is 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.

Changes requested — before the latest push

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:

  1. 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 under src/tui/ (an admin entry per AGENTS.md) that shows the plan line and sets the portal address, gated like the page, or justify with a No door: <why> commit line that covers the setting too.
  2. Tests. Nothing proves the wiring: no test that grantPlan, settleGift, make (spawn), bindCheckout or syncSubscription call tellPlan, that tellAll rewrites every forge when the portal changes, that billingSetPortal rejects non-admins, agents and a cross-origin post, or that /settings actually renders the row for an admin and not for a member. Remove each tellPlan call and the tests still pass. Add those tests.
  3. 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 in docs/design/areas/. A fact is a dl row, not a run of dots in a paragraph. Use the existing dl row component, or document the class in your area file. Say the credit in one plain sentence.
  4. Docs. The how-to belongs in docs/features/, which you did. Keep README untouched.
  5. Clean. noticeOf has a redundant typeof p !== "object" check after readDoc already guarantees an object. Import order in actions/*.ts puts the new import above type { App }; keep it with the others.
commented

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 through changePortalUrl, so the gate and validation are the page's.
  • Design: the row is now one status-rows dl row labelled "Plan", with no plan-row class 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 typeof check and cast in noticeOf, and moved the tellPlan imports in actions/*.ts and billing/checkout_completed.ts to sit after the type { App } import.
  • Docs: docs/features/plan-row.md now describes the terminal screen, and the README is untouched.

Tests

  • test/plan-wiring.test.ts: I removed the tellPlan calls in grantPlan, settleGift, bindCheckout and syncSubscription and 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 own plan.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.

commented

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.

commented

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-row palette entry ("Plan", from src/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.ts passes (15 tests) and bun x tsc --noEmit is 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 perfRecord ledger, 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.ts or scripts/fast.ts.

The fix is pushed as 2c302d8ce.

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs