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

scout/forge:scout/11141-a-forge-s-owner-sees-their-plan-in-their → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/11141-a-forge-s-owner-sees-their-plan-in-their was merged into main as 7da9734.

Review

Sign in to review this change.
20 files changed +629 −7
docs/features/plan-row.md added +7
@@ -0,0 +1,7 @@
1 # The Plan row in a forge's settings (#11141)
2
3 A forge's admins see one `status-rows` `dl` row (`Plan`) at the top of `/settings`: the plan's name and size, the crew credit used this month, and the status (`Active`, or `Past due, sleeps <date>`). With a billing portal set and a Stripe payer, **Manage billing** links to the provider's customer-portal sign-in (the buyer signs in with the email they paid with); **Change size** links to the host's `/pricing`. A forge with no notice (never told, or a host that is not its parent) shows no row.
4
5 - **Host:** Settings → Billing → *Billing portal* sets `billing.portal_url` (https only, empty clears). Changing it tells every forge sent from here.
6 - **How a forge learns its plan:** the host writes a notice (`src/billing/notice.ts`) into that one forge's own `plan.json` (never `parent.json`) 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, so it cannot read another forge's plan; the notice has no forge name, customer or subscription id.
7 - **Terminal:** the palette entry *Plan* (`src/tui/screens/planrow.ts`, any admin) shows the same plan line (`planLine`) and, for an admin who is a person, sets the billing portal address with Enter through `changePortalUrl`, as the page does.
src/actions/billing.ts +2
@@ -7,6 +7,7 @@
77 */
88
99 import type { App } from "../app.ts";
10 import { tellPlan } from "../billing/notice.ts";
1011 import { changesOf, entitlementOf, putEntitlement, type EntitlementChange } from "../billing/store.ts";
1112 import type { Entitlement } from "../billing/types.ts";
1213 import type { User } from "../core/users.ts";
@@ -87,6 +88,7 @@ export function grantPlan(app: App, actor: User | undefined, forge: unknown, pla
8788 const { from, to } = planned.value;
8889 const size = listChildrenNow(app).find((c) => c.name === to.forge)?.size ?? from.plan;
8990 const entitlement = putEntitlement(app.db, { forge: to.forge, plan: to.plan, status: "active", source: "granted", provider: "none" }, size, who.id);
91 tellPlan(app, to.forge);
9092 app.bus.append({ kind: "billing.plan", actorId: who.id, payload: { forge: to.forge, from: from.plan, to: to.plan, source: "granted" } });
9193 return ok({ entitlement, changes: changesOf(app.db, to.forge) });
9294 }
src/actions/codes.ts +2
@@ -9,6 +9,7 @@
99 */
1010
1111 import type { App } from "../app.ts";
12 import { tellPlan } from "../billing/notice.ts";
1213 import {
1314 CODE_WORDS, MAX_NOTE, MAX_USES, insertCode, listCodes, lookupCode, noteRedeemed,
1415 releaseCode, revokeCode, takeTry, type Code, type CodeRefusal, type Listed,
@@ -112,5 +113,6 @@ export function settleGift(app: App, code: Code, forge: string): void {
112113 const size = getPlan(app.db, code.plan)?.size ?? "small";
113114 noteRedeemed(app.db, code.id, forge);
114115 putEntitlement(app.db, { forge, plan: code.plan, status: "active", source: `code:${code.id}`, provider: "none" }, size, "");
116 tellPlan(app, forge);
115117 app.bus.append({ kind: "billing.code.redeem", payload: { id: code.id, forge, plan: code.plan } });
116118 }
src/actions/signup.ts +11 −1
@@ -14,7 +14,8 @@ import {
1414 } from "../settings/signup.ts";
1515 import { getAgentStart, parseAgentStart, setAgentStart, type AgentStartMode } from "../settings/agent_start.ts";
1616 import { forgeSource, parseForgeSource, setForgeSource, storedForgeSource } from "../settings/forge_source.ts";
17 import { getBillingLinks, parseBillingLinks, setBillingLinks, type BillingLinks } from "../settings/billing.ts";
17 import { tellAll } from "../billing/notice.ts";
18 import { getBillingLinks, parseBillingLinks, setBillingLinks, setPortalUrl, type BillingLinks } from "../settings/billing.ts";
1819 import type { User } from "../core/users.ts";
1920 import { err, ok, type Result } from "../kernel/result.ts";
2021
@@ -110,3 +111,12 @@ export function changeBillingLinks(
110111 setBillingLinks(app.db, links.value, who.value.id);
111112 return ok(links.value);
112113 }
114
115 /** The billing portal address (`settings/billing.ts`) and every forge told it, for a forge admin who is a person. */

Both doors: setting the portal address has no terminal entry. Add a screen under src/tui/ (admin entry, same gate as this function), or a No door: <why> commit line. Also add a test that tellAll reaches every forge.

116 export function changePortalUrl(app: App, actor: User | undefined, raw: unknown): Result<string, "not_found" | "invalid_portal_url"> {
117 const who = admin(actor);
118 if (!who.ok) return who;
119 const set = setPortalUrl(app.db, raw, who.value.id);
120 if (set.ok) tellAll(app);
121 return set;
122 }
src/actions/spawn.ts +2
@@ -16,6 +16,7 @@ import { readdirSync, readFileSync } from "node:fs";
1616 import { mkdir, readdir, rename } from "node:fs/promises";
1717 import { join } from "node:path";
1818 import { createApp, type App } from "../app.ts";
19 import { tellPlan } from "../billing/notice.ts";
1920 import {
2021 addCrew,
2122 BRIEF_MAX,
@@ -336,6 +337,7 @@ async function make(
336337 grantPlanCredit(app, plan, ledger.bodyId);
337338 ledger.done = true;
338339 await writeLedger(app, ledger);
340 tellPlan(app, plan.name);
339341 app.bus.append({
340342 kind: "forge.spawned",
341343 actorId: owner?.id,
src/billing/checkout_completed.ts +2
@@ -8,6 +8,7 @@
88 */
99
1010 import type { App } from "../app.ts";
11 import { tellPlan } from "./notice.ts";
1112 import { listChildrenNow } from "../actions/spawn.ts";
1213 import { parseSize } from "../spawn/sizes.ts";
1314 import { entitlementOf, putEntitlement } from "./store.ts";
@@ -41,6 +42,7 @@ export function bindCheckout(app: App, event: unknown): boolean {
4142 child.size,
4243 "",
4344 );
45 tellPlan(app, forge);
4446 app.bus.append({ kind: "billing.plan", payload: { forge, to: plan, source: "stripe" } });
4547 return true;
4648 }
src/billing/notice.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * What a forge is told of its own plan (#11141): its plan's name and size,
3 * its status and period end, and the host's public billing-portal address
4 * (`billing.portal_url`) when it pays through Stripe. The host writes it into
5 * that one child's own `plan.json` (not `parent.json`, which holds nothing of the parent's) when the forge is made and
6 * whenever its entitlement changes; the child only ever reads its own file.
7 * No call from a forge reaches the host for it, so a forge cannot learn
8 * another's plan, and the notice carries no forge name, customer or
9 * subscription id.
10 */
11
12 import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13 import { join } from "node:path";
14 import type { App } from "../app.ts";
15 import { childDirOf, listChildrenNow } from "../actions/spawn.ts";
16 import { getPortalUrl, parseLink } from "../settings/billing.ts";
17 import { getPlan } from "../settings/plans.ts";
18 import { entitlementOf, getStored } from "./store.ts";
19 import type { EntitlementStatus } from "./types.ts";
20
21 export interface PlanNotice {
22 readonly name: string;
23 readonly size: string;
24 readonly priceUsd: number;
25 readonly creditUsd: number;
26 readonly status: EntitlementStatus;
27 /** Unix seconds the paid period ends (and a past-due forge sleeps), when known. */
28 readonly periodEnd?: number;
29 /** The provider's customer-portal sign-in; absent without a provider or a URL. */
30 readonly portalUrl?: string;
31 }
32
33 const file = (dataDir: string): string => join(dataDir, "plan.json");
34 const parentFile = (dataDir: string): string => join(dataDir, "parent.json");
35 const STATUSES: readonly string[] = ["active", "grace", "none"];
36
37 function readDoc(dataDir: string): Record<string, unknown> | undefined {
38 try {
39 const d = JSON.parse(readFileSync(file(dataDir), "utf8"));
40 return typeof d === "object" && d !== null && !Array.isArray(d) ? (d as Record<string, unknown>) : undefined;
41 } catch {
42 return undefined;
43 }
44 }
45
46 const short = (v: unknown, max: number): string | undefined => (typeof v === "string" && v.length > 0 && v.length <= max && !/[\x00-\x1f]/.test(v) ? v : undefined);
47 const money = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 100_000 ? v : 0);
48
49 /** This forge's own notice, validated on read; undefined when it was told none. */
50 export function noticeOf(dataDir: string): PlanNotice | undefined {
51 const r = readDoc(dataDir);
52 if (r === undefined) return undefined;
outdated

Redundant: readDoc already returns a plain object or undefined. Drop the typeof check and the cast.

53 const name = short(r["name"], 60);
54 const size = short(r["size"], 20);
55 if (name === undefined || size === undefined || typeof r["status"] !== "string" || !STATUSES.includes(r["status"])) return undefined;
56 const portal = typeof r["portalUrl"] === "string" ? parseLink(r["portalUrl"]) : undefined;
57 return {
58 name,
59 size,
60 priceUsd: money(r["priceUsd"]),
61 creditUsd: money(r["creditUsd"]),
62 status: r["status"] as EntitlementStatus,
63 ...(typeof r["periodEnd"] === "number" && Number.isFinite(r["periodEnd"]) && r["periodEnd"] > 0 ? { periodEnd: r["periodEnd"] } : {}),
64 ...(portal === undefined || portal === "" ? {} : { portalUrl: portal }),
65 };
66 }
67
68 /** Write the notice into the child's own `plan.json`; false when the child has no `parent.json` yet. */
69 export function keepNotice(dataDir: string, notice: PlanNotice): boolean {
70 if (!existsSync(parentFile(dataDir))) return false;
71 writeFileSync(`${file(dataDir)}.tmp`, JSON.stringify(notice), { mode: 0o600 });
72 renameSync(`${file(dataDir)}.tmp`, file(dataDir));
73 return true;
74 }
75
76 /** Tell one forge its plan as the host holds it now. Never throws: a forge that cannot be reached is told at the next change. */
77 export function tellPlan(app: App, forge: string): boolean {
78 try {
79 const child = listChildrenNow(app).find((c) => c.name === forge && c.done);
80 if (child === undefined) return false;
81 const e = entitlementOf(app.db, forge, child.size);
82 const plan = getPlan(app.db, e.plan);
83 const portal = getPortalUrl(app.db);
84 const paysAtProvider = getStored(app.db, forge)?.provider === "stripe";
85 return keepNotice(childDirOf(app, child.bodyId), {
86 name: plan?.name ?? e.plan,
87 size: plan?.size ?? child.size,
88 priceUsd: plan?.priceUsd ?? 0,
89 creditUsd: plan?.creditUsd ?? 0,
90 status: e.status,
91 ...(e.periodEnd === undefined ? {} : { periodEnd: e.periodEnd }),
92 ...(paysAtProvider && portal !== "" ? { portalUrl: portal } : {}),
93 });
94 } catch {
95 return false;
96 }
97 }
98
99 /** Tell every forge sent from here, after the portal address changes. */
100 export function tellAll(app: App): void {
101 for (const c of listChildrenNow(app)) tellPlan(app, c.name);
102 }
src/billing/sync.ts +2
@@ -10,6 +10,7 @@
1010
1111 import type { App } from "../app.ts";
1212 import { listChildrenNow } from "../actions/spawn.ts";
13 import { tellPlan } from "./notice.ts";
1314 import { openKey } from "./connection.ts";
1415 import { getStored, putEntitlement } from "./store.ts";
1516 import { stripeClient, type StripeError } from "./stripe.ts";
@@ -50,6 +51,7 @@ export async function syncSubscription(app: App, id: string | undefined): Promis
5051 if (put === undefined) return;
5152 const size = listChildrenNow(app).find((c) => c.name === put.forge)?.size ?? getStored(app.db, put.forge)?.plan ?? "small";
5253 putEntitlement(app.db, put, size, "stripe");
54 tellPlan(app, put.forge);
5355 });
5456 return failed;
5557 }
src/http/settings_routes.ts +2 −1
@@ -18,7 +18,7 @@ import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts
1818 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1919 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
2020 import { appTemplatesPage, appTemplatesSet } from "../web/pages/app_templates.ts";
21 import { billingPage, billingGrant, billingSetLinks, billingMakeCode, billingRevokeCode, stripeConnect, stripeDisconnect, stripeSecret, orderRetry } from "../web/pages/billing.ts";
21 import { billingPage, billingGrant, billingSetLinks, billingSetPortal, billingMakeCode, billingRevokeCode, stripeConnect, stripeDisconnect, stripeSecret, orderRetry } from "../web/pages/billing.ts";
2222 import { economicsPage, economicsSet } from "../web/pages/economics.ts";
2323 import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts";
2424 import { usagePage } from "../web/pages/usage.ts";
@@ -212,6 +212,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
212212 router.add("GET", "/settings/billing", billingPage);
213213 router.add("POST", "/settings/billing/grant", billingGrant);
214214 router.add("POST", "/settings/billing/links", billingSetLinks);
215 router.add("POST", "/settings/billing/portal", billingSetPortal);
215216 router.add("POST", "/settings/billing/stripe/connect", stripeConnect);
216217 router.add("POST", "/settings/billing/stripe/secret", stripeSecret);
217218 router.add("POST", "/settings/billing/stripe/disconnect", stripeDisconnect);
src/settings/billing.ts +19 −1
@@ -20,7 +20,7 @@ const MAX_URL = 300;
2020 export const BILLING_WORDS = "billing links are an object of small, medium and large, each an https URL or empty";
2121
2222 /** One https URL with no credentials and no spaces, or the empty string. */
23 function parseLink(raw: unknown): string | undefined {
23 export function parseLink(raw: unknown): string | undefined {
2424 if (raw === null || raw === "") return "";
2525 if (typeof raw !== "string") return undefined;
2626 const s = raw.trim();
@@ -80,3 +80,21 @@ export function sizeWords(size: Size): string {
8080 const gb = (mib: number): string => `${mib / 1024} GB`;
8181 return `${s.cpu} ${s.cpu === 1 ? "CPU" : "CPUs"}, ${gb(s.memoryMiB)} memory, ${gb(s.diskMiB)} disk`;
8282 }
83
84 export const PORTAL_URL_KEY = "billing.portal_url";
85 export const PORTAL_WORDS = "the billing portal address is one https URL with no spaces, or empty";
86
87 /** The provider's customer-portal sign-in, one https URL the host sets; "" when none. */
88 export function getPortalUrl(db: Database): string {
89 const row = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(PORTAL_URL_KEY);
90 return row === null ? "" : (parseLink(row.value) ?? "");
91 }
92
93 /** Keep the portal URL (validated, empty clears it) with who changed it; the same value again keeps nothing. */
94 export function setPortalUrl(db: Database, raw: unknown, actorId: string): Result<string, "invalid_portal_url"> {
95 const link = parseLink(raw);
96 if (link === undefined) return err("invalid_portal_url");
97 const from = getPortalUrl(db);
98 if (from !== link) writeSetting(db, PORTAL_URL_KEY, link === "" ? null : link, from, link, actorId);
99 return ok(link);
100 }
src/tui/screens/entries.ts +2
@@ -50,6 +50,7 @@ import { entry as homePins } from "./home_pins.ts";
5050 import { entry as claimStart } from "./claim_start.ts";
5151 import { entry as invites } from "./invites.ts";
5252 import { entry as billingLinks } from "./billinglinks.ts";
53 import { entry as planRow } from "./planrow.ts";
5354 import { entry as limits } from "./limits.ts";
5455 import { entry as plans } from "./plans.ts";
5556 import { entry as economics } from "./economics.ts";
@@ -162,6 +163,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
162163 archived,
163164 invites,
164165 billingLinks,
166 planRow,
165167 claimStart,
166168 requests,
167169 bulk,
src/tui/screens/planrow.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * The Plan row in the terminal (#11141): the plan line a forge's admins see
3 * at the top of /settings (`planLine`, the page's own words) and, for an
4 * admin who is a person, the billing portal address with Enter to set it,
5 * through `changePortalUrl` so the gate and the checks are the page's.
6 */
7
8 import { changePortalUrl } from "../../actions/signup.ts";
9 import { getPortalUrl } from "../../settings/billing.ts";
10 import { planLine } from "../../web/plan_row.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import { style } from "../term/style.ts";
14 import { truncate } from "../term/width.ts";
15 import type { Session } from "../session.ts";
16 import { fill, len, split } from "../ui/layout.ts";
17 import { ListState, drawList, promptScreen } from "./common.ts";
18 import type { PaletteEntry } from "./entries.ts";
19
20 const person = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind === "user";
21
22 export function planRowScreen(f: Session, ctx: Ctx): Screen {
23 const state = new ListState();
24 let note = "";
25 const screen: Screen = {
26 title: "Plan",
27 keys: [binding(["enter"], "set the billing portal address")],
28 render(area, s) {
29 const c = ctx.theme.c;
30 const [head, body] = split(area, "v", [len(1), fill(1)]);
31 const text = note !== "" ? note : (planLine(f.app.config.dataDir) ?? "This forge was told no plan.");
32 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
33 drawList(s, body!, c, state, [{ title: "Billing portal", secondary: getPortalUrl(f.app.db) || "no link" }]);
34 },
35 key(e) {
36 note = "";
37 if (e.ctrl || e.alt || e.key !== "enter" || !person(f)) return false;
38 ctx.push(
39 promptScreen(ctx, screen, {
40 title: "Billing portal",
41 label: "https address, empty for none",
42 value: getPortalUrl(f.app.db),
43 onSubmit: (v) => {
44 const set = changePortalUrl(f.app, f.user, v.trim());
45 if (!set.ok) return set.error === "not_found" ? "You cannot change the billing portal." : "The billing portal is one https address, or empty.";
46 note = `Billing portal: ${set.value || "no link"}.`;
47 ctx.invalidate();
48 return undefined;
49 },
50 }),
51 );
52 return true;
53 },
54 };
55 return screen;
56 }
57
58 /** The plan line and the portal address: an admin of the forge. */
59 export const entry: PaletteEntry = {
60 id: "plan-row",
61 title: "Plan",
62 hint: "this forge's plan, and where its owner manages billing",
63 after: ["billing-links", "limits"],
64 offered: (session) => session.user?.isAdmin === true,
65 run: (session, ctx) => ctx.push(planRowScreen(session, ctx)),
66 };
src/web/pages/account.ts +2 −1
@@ -62,6 +62,7 @@ import {
6262 import { accountSections } from "./origins.ts";
6363 import { field, readForm } from "../form.ts";
6464 import { html, when, type Html } from "../html.ts";
65 import { planRow } from "../plan_row.ts";
6566 import { flashTo, page } from "../layout.ts";
6667 import { readFlash } from "../flash.ts";
6768 import {
@@ -519,7 +520,7 @@ export function accountPage(ctx: Ctx): Response {
519520 ctx,
520521 "profile",
521522 "Settings",
522 html`${pair}${profile}${edge}${parent}${notify}`,
523 html`${planRow(ctx.app.config.dataDir, parentIssuerOf(ctx.app.config), user.isAdmin === true)}${pair}${profile}${edge}${parent}${notify}`,
523524 200,
524525 // The everyday form makes its account with a passkey (#1890).
525526 pair ? passkeyJs : undefined,
src/web/pages/billing.ts +30 −3
@@ -15,9 +15,9 @@ import { publicOrigin } from "../../core/public_origin.ts";
1515 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1616 import type { Ctx } from "../../http/context.ts";
1717 import { SIZES } from "../../spawn/sizes.ts";
18 import { BILLING_SIZES, parseBillingLinks } from "../../settings/billing.ts";
18 import { BILLING_SIZES, getPortalUrl, PORTAL_WORDS, parseBillingLinks } from "../../settings/billing.ts";
1919 import { getPlans } from "../../settings/plans.ts";
20 import { changeBillingLinks, readBillingLinks } from "../../actions/signup.ts";
20 import { changeBillingLinks, changePortalUrl, readBillingLinks } from "../../actions/signup.ts";
2121 import { funnelReport, type FunnelReport } from "../../perf/funnel.ts";
2222 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
2323 import { field, readForm } from "../form.ts";
@@ -106,6 +106,32 @@ function linksSection(ctx: Ctx, retry?: { values: Record<string, string>; error:
106106 </section>`;
107107 }
108108
109 /** The one billing-portal address every paying forge's Plan row links to. */
110 function portalSection(ctx: Ctx, retry?: { value: string; error: string }): Html | null {
111 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return null;
112 return html`<section class="settings-block" id="portal">
113 <h2>Billing portal</h2>
114 <p class="muted small">Stripe's customer-portal sign-in. A forge that pays through Stripe links its owner here to change a card; empty shows no link.</p>
115 ${retry === undefined ? null : html`<p class="error" role="alert">${retry.error}</p>`}
116 <form class="stack" method="post" action="${PATH}/portal">
117 ${formField({ label: "Portal sign-in address", name: "portal_url", type: "url", value: retry?.value ?? getPortalUrl(ctx.app.db), hint: "https://billing.stripe.com/p/login/…" })}
118 ${btn("Save portal address", { variant: "primary" })}
119 </form>
120 </section>`;
121 }
122
123 // POST /settings/billing/portal
124 export async function billingSetPortal(ctx: Ctx): Promise<Response> {
125 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
126 const form = await readForm(ctx, "admin");
127 if (form instanceof Response) return form;
128 const value = field(form, "portal_url");
129 const changed = changePortalUrl(ctx.app, ctx.auth.user, value);
130 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
131 if (changed.ok) return flashTo(ctx, `${PATH}#portal`, { message: "Billing portal address saved." });
132 return billingView(ctx, undefined, {}, undefined, 422, undefined, { value, error: `${PORTAL_WORDS}. Nothing was saved.` });
133 }
134
109135 // POST /settings/billing/links
110136 export async function billingSetLinks(ctx: Ctx): Promise<Response> {
111137 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
@@ -144,7 +170,7 @@ export function billingPage(ctx: Ctx): Response {
144170 return billingView(ctx);
145171 }
146172
147 function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {}, error?: string, status = 200, links?: { values: Record<string, string>; error: string }): Response {
173 function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {}, error?: string, status = 200, links?: { values: Record<string, string>; error: string }, portal?: { value: string; error: string }): Response {
148174 const read = readBilling(ctx.app, ctx.auth.user);
149175 if (!read.ok) return notFoundPage(ctx);
150176 const b = read.value;
@@ -213,6 +239,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {
213239 ${ledBlock(ctx)}
214240 ${funnelRow(funnelReport(ctx.app.db, 7))}
215241 ${linksSection(ctx, links)}
242 ${portalSection(ctx, portal)}
216243 ${codesSection(ctx, shown, values, error)}`;
217244 const response = page({
218245 ctx,
src/web/plan_row.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * The Plan row in a forge's settings (#11141), one `dl` row for its admins: the
3 * plan's name and size, the crew credit used this month, and the status (past
4 * due says the date it sleeps). Everything is what the host told this forge
5 * (`billing/notice.ts`) and its own funding file; nothing is asked of the
6 * host. "Manage billing" is the provider's portal sign-in, shown only when
7 * the host set one and this forge pays through it; "Change size" is the
8 * host's /pricing.
9 */
10
11 import { noticeOf, type PlanNotice } from "../billing/notice.ts";
12 import { fundingOf } from "../spawn/funding.ts";
13 import { usdWords } from "../spawn/credit.ts";
14 import { sizeWords } from "../settings/billing.ts";
15 import { isSize } from "../spawn/sizes.ts";
16 import { html, type Html } from "./html.ts";
17
18 const day = (secs: number): string => new Date(secs * 1000).toISOString().slice(0, 10);
19
20 /** "Active", or "Past due, sleeps 2026-12-01". */
21 export function statusWords(n: Pick<PlanNotice, "status" | "periodEnd">): string {
22 if (n.status === "grace") return n.periodEnd === undefined ? "Past due" : `Past due, sleeps ${day(n.periodEnd)}`;
23 return n.status === "none" ? "No active plan" : "Active";
24 }
25
26 /** The plan as one plain line of text, or undefined when this forge was told none; the page and the terminal say the same. */
27 export function planLine(dataDir: string): string | undefined {
28 const n = noticeOf(dataDir);
29 if (n === undefined) return undefined;
outdated

Principles 1, 3, 6, 7: a fact is a dl row, not a paragraph joined by ' · ' with an unstyled, undocumented plan-row class. Use the named dl row, or document the class in docs/design/areas/<area>.md.

30 const f = fundingOf(dataDir);
31 const size = isSize(n.size) ? ` (${sizeWords(n.size)})` : "";
32 const credit = n.creditUsd > 0 && f?.spentUsd != null ? ` ${usdWords(f.spentUsd)} of ${usdWords(n.creditUsd)} crew credit used this month.` : "";
33 return `${n.name}${size}. ${statusWords(n)}.${credit}`;
34 }
35
36 /** One `dl` row for a forge's admins; null for anyone else or a forge told nothing. */
37 export function planRow(dataDir: string, parentOrigin: string | undefined, admin: boolean): Html | null {
38 if (!admin) return null;
39 const line = planLine(dataDir);
40 if (line === undefined) return null;
41 const portal = noticeOf(dataDir)?.portalUrl;
42 return html`<dl class="status-rows"><div class="status-row"><dt>Plan</dt><dd>${line}${
43 portal === undefined ? "" : html` <a href="${portal}" rel="noopener noreferrer" target="_blank">Manage billing</a>`
44 }${parentOrigin === undefined ? "" : html` <a href="${parentOrigin}/pricing">Change size</a>`}</dd></div></dl>`;
45 }
test/palette-roster.test.ts +2
@@ -155,6 +155,7 @@ test("a site-admin person is offered the whole roster", () => {
155155 ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"],
156156 ["teams", "Crew teams", "each team's charter, dial and members"],
157157 ["billing-links", "Buy links", "where each hosted plan is paid for, shown on the home page"],
158 ["plan-row", "Plan", "this forge's plan, and where its owner manages billing"],
158159 ["agents", "Crew agents", "each agent's model, budget and brief"],
159160 ["invites", "Invite links", "make a single-use link, see which are open, revoke one"],
160161 ["claim-start", "Take an agent's work", "claim the repositories an agent started, from its one-time link"],
@@ -260,6 +261,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
260261 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
261262 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
262263 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],
264 ["plan-row", "Plan", "this forge's plan, and where its owner manages billing"],
263265 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
264266 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],
265267 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes, h who liked"],
test/plan-notice.test.ts added +111
@@ -0,0 +1,111 @@
1 /**
2 * A forge's Plan row (#11141): the host tells each forge its own plan (name,
3 * size, status, the public portal URL) and nothing of any other; the row
4 * shows it to the forge's admins, in one line, with Manage billing only when
5 * a portal URL is set and the forge pays through Stripe.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { childDirOf } from "../src/actions/spawn.ts";
12 import { changePortalUrl } from "../src/actions/signup.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { noticeOf, tellPlan } from "../src/billing/notice.ts";
15 import { putEntitlement } from "../src/billing/store.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { getPortalUrl } from "../src/settings/billing.ts";
18 import { planRow, statusWords } from "../src/web/plan_row.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 describe("plan notice", () => {
24 let dir: string;
25 let app: App;
26 let admin: User;
27 const PORTAL = "https://billing.stripe.com/p/login/abc123";
28
29 const child = async (name: string): Promise<string> => {
30 const bodyId = `body-${name}`;
31 await mkdir(join(dir, "spawn"), { recursive: true });
32 await writeFile(join(dir, "spawn", `${name}.json`), JSON.stringify({ name, bodyId, done: true, size: "small", org: name, apps: [] }));
33 const cd = childDirOf(app, bodyId);
34 await mkdir(cd, { recursive: true });
35 await writeFile(join(cd, "parent.json"), JSON.stringify({ upstream: "https://host.example" }));
36 return cd;
37 };
38
39 beforeAll(async () => {
40 dir = await mkdtemp(join(tmpdir(), "forge-notice-"));
41 app = createApp({ dataDir: dir, port: 0 });
42 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
43 if (!a.ok) throw new Error("user");
44 admin = a.value;
45 });
46 afterAll(async () => {
47 app.close?.();
48 await rm(dir, { recursive: true, force: true });
49 });
50
51 test("the portal URL is https only, kept for admins, and empty clears it", () => {
52 expect(changePortalUrl(app, admin, "http://x.example/").ok).toBe(false);
53 expect(changePortalUrl(app, admin, "https://u:[email protected]/").ok).toBe(false);
54 expect(changePortalUrl(app, { ...admin, isAdmin: false }, PORTAL)).toEqual({ ok: false, error: "not_found" });
55 expect(getPortalUrl(app.db)).toBe("");
56 expect(changePortalUrl(app, admin, PORTAL).ok).toBe(true);
57 expect(getPortalUrl(app.db)).toBe(PORTAL);
58 });
59
60 test("a forge is told its own plan, and only its own; a Stripe payer gets the portal link", async () => {
61 const a = await child("alpha");
62 const b = await child("beta");
63 putEntitlement(app.db, { forge: "alpha", plan: "medium", status: "grace", periodEnd: 1_796_000_000, provider: "stripe", customerId: "cus_secret1", subscriptionId: "sub_secret1", source: "stripe" }, "small", "");
64 expect(tellPlan(app, "alpha")).toBe(true);
65 const n = noticeOf(a)!;
66 expect(n).toMatchObject({ name: "Medium", size: "medium", status: "grace", portalUrl: PORTAL });
67 // beta was never told: no call and no file of alpha's reaches it.
68 expect(noticeOf(b)).toBeUndefined();
69 tellPlan(app, "beta");
70 const told = noticeOf(b)!;
71 expect(told.name).toBe("Small");
72 expect(told.portalUrl).toBeUndefined();
73 // Nothing of another forge, nor any provider id, is in what a forge holds.
74 for (const d of [a, b]) {
75 const raw = await readFile(join(d, "plan.json"), "utf8");
76 expect(raw).not.toContain("cus_secret1");
77 expect(raw).not.toContain("sub_secret1");
78 expect(raw).not.toContain(d === a ? "beta" : "alpha");
79 }
80 expect(tellPlan(app, "nobody")).toBe(false);
81 });
82
83 test("the row is one line for admins only, with the status date and the links", async () => {
84 const a = childDirOf(app, "body-alpha");
85 const row = planRow(a, "https://host.example", true)!.toString();
86 expect(row.split("\n").length).toBe(1);
87 expect(row).toContain("<dt>Plan</dt>");
88 expect(row).not.toContain(" · ");
89 expect(row).toContain("Medium");
90 expect(row).toContain("Past due, sleeps 2026-");
91 expect(row).toContain(`href="${PORTAL}"`);
92 expect(row).toContain('href="https://host.example/pricing"');
93 expect(planRow(a, "https://host.example", false)).toBeNull();
94 // No portal URL for a forge that does not pay through Stripe: the plan, no link.
95 const free = planRow(childDirOf(app, "body-beta"), undefined, true)!.toString();
96 expect(free).toContain("Small");
97 expect(free).not.toContain("Manage billing");
98 expect(free).not.toContain("Change size");
99 // A forge told nothing shows no row.
100 expect(planRow(join(dir, "nowhere"), undefined, true)).toBeNull();
101 });
102
103 test("a hostile notice is refused on read", async () => {
104 const cd = childDirOf(app, "body-beta");
105 await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "x", size: "small", status: "active", portalUrl: "javascript:alert(1)" }));
106 expect(noticeOf(cd)?.portalUrl).toBeUndefined();
107 await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "x", size: "small", status: "bogus" }));
108 expect(noticeOf(cd)).toBeUndefined();
109 expect(statusWords({ status: "active" })).toBe("Active");
110 });
111 });
test/plan-wiring.test.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * The Plan row's wiring (#11141): every change to a forge's plan tells that
3 * forge (grant, gift, checkout, Stripe sync), a new portal address tells every
4 * forge, the portal setting is a person admin's alone and same-origin, and
5 * /settings shows the row to an admin and to no one else. Each test fails if
6 * the call it names is removed.
7 */
8 import { afterAll, beforeAll, expect, test } from "bun:test";
9 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { grantPlan } from "../src/actions/billing.ts";
13 import { makeCode, settleGift } from "../src/actions/codes.ts";
14 import { changePortalUrl } from "../src/actions/signup.ts";
15 import { childDirOf } from "../src/actions/spawn.ts";
16 import { createApp, type App } from "../src/app.ts";
17 import { bindCheckout } from "../src/billing/checkout_completed.ts";
18 import { sealKey } from "../src/billing/connection.ts";
19 import { keepNotice, noticeOf } from "../src/billing/notice.ts";
20 import { putEntitlement } from "../src/billing/store.ts";
21 import { setStripeFetch, syncSubscription } from "../src/billing/sync.ts";
22 import { createSession } from "../src/core/sessions.ts";
23 import { createUser, type User } from "../src/core/users.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25 import { getPortalUrl } from "../src/settings/billing.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27 import { promote } from "./helpers/members.ts";
28
29 quietLogs();
30
31 const PORTAL = "https://billing.stripe.com/p/login/wired";
32 let dir: string;
33 let app: App;
34 let servers: Servers;
35 let admin: User;
36 let member: User;
37
38 async function child(name: string): Promise<string> {
39 const bodyId = `body-${name}`;
40 await mkdir(join(dir, "spawn"), { recursive: true });
41 await writeFile(join(dir, "spawn", `${name}.json`), JSON.stringify({ name, bodyId, done: true, size: "small", org: name, apps: [] }));
42 const cd = childDirOf(app, bodyId);
43 await mkdir(cd, { recursive: true });
44 await writeFile(join(cd, "parent.json"), JSON.stringify({ upstream: "https://host.example" }));
45 return cd;
46 }
47
48 beforeAll(async () => {
49 dir = await mkdtemp(join(tmpdir(), "forge-wiring-"));
50 app = createApp({ dataDir: dir, port: 0 });
51 servers = await serve(app);
52 const make = async (username: string) => {
53 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", allowReserved: true, adminIfFirst: true });
54 if (!made.ok) throw new Error(made.error);
55 return made.value;
56 };
57 admin = await make("owner");
58 promote(app.db, admin);
59 member = await make("mia");
60 });
61 afterAll(async () => {
62 setStripeFetch(app, undefined);
63 await servers.stop();
64 await rm(dir, { recursive: true, force: true });
65 });
66
67 test("a grant tells that forge its plan", async () => {
68 const cd = await child("g1");
69 expect(noticeOf(cd)).toBeUndefined();
70 expect(grantPlan(app, admin, "g1", "medium").ok).toBe(true);
71 expect(noticeOf(cd)).toMatchObject({ name: "Medium", status: "active" });
72 });
73
74 test("a redeemed gift tells the forge its plan", async () => {
75 const cd = await child("g2");
76 const made = makeCode(app, admin, { plan: "large" }, "http://x");
77 if (!made.ok) throw new Error(made.error);
78 settleGift(app, made.value.made, "g2");
79 expect(noticeOf(cd)).toMatchObject({ name: "Large", status: "active" });
80 });
81
82 test("a completed checkout and a Stripe sync tell the forge, with the portal link for a payer", async () => {
83 expect(changePortalUrl(app, admin, PORTAL).ok).toBe(true);
84 const cd = await child("g3");
85 const bound = bindCheckout(app, { data: { object: { mode: "subscription", payment_status: "paid", customer: "cus_w", subscription: "sub_w", metadata: { forge: "g3", plan: "medium" } } } });
86 expect(bound).toBe(true);
87 expect(noticeOf(cd)).toMatchObject({ name: "Medium", status: "active", portalUrl: PORTAL });
88
89 sealKey(app.db, dir, "sk_test_abcdefgh12345678");
90 setStripeFetch(app, (async () => new Response(JSON.stringify({ id: "sub_w", status: "past_due", customer: "cus_w", metadata: { forge: "g3", plan: "medium" }, items: { data: [{ current_period_end: 1900000000 }] } }), { status: 200 })) as unknown as typeof fetch);
91 await syncSubscription(app, "sub_w");
92 expect(noticeOf(cd)).toMatchObject({ status: "grace", periodEnd: 1900000000 });
93 });
94
95 test("a new portal address tells every forge; clearing it removes the link", async () => {
96 const a = await child("t1");
97 const b = await child("t2");
98 for (const f of ["t1", "t2"]) putEntitlement(app.db, { forge: f, plan: "medium", status: "active", source: "stripe", provider: "stripe", customerId: `cus_${f}`, subscriptionId: `sub_${f}` }, "small", "");
99 const next = "https://billing.stripe.com/p/login/second";
100 expect(changePortalUrl(app, admin, next).ok).toBe(true);
101 expect(noticeOf(a)?.portalUrl).toBe(next);
102 expect(noticeOf(b)?.portalUrl).toBe(next);
103 expect(changePortalUrl(app, admin, "").ok).toBe(true);
104 expect(noticeOf(a)?.portalUrl).toBeUndefined();
105 expect(noticeOf(b)?.portalUrl).toBeUndefined();
106 });
107
108 test("the portal setting refuses a member, an agent and a cross-origin post; nothing changes", async () => {
109 const before = getPortalUrl(app.db);
110 expect(changePortalUrl(app, member, PORTAL)).toEqual({ ok: false, error: "not_found" });
111 expect(changePortalUrl(app, { ...admin, kind: "agent" }, PORTAL)).toEqual({ ok: false, error: "not_found" });
112 const post = (cookie: string, origin: string) =>
113 fetch(`${servers.url}/settings/billing/portal`, { method: "POST", headers: { cookie, origin, accept: "text/html" }, body: new URLSearchParams({ portal_url: PORTAL }), redirect: "manual" });
114 const mine = `forge_session=${createSession(app.db, member.id).token}`;
115 const theirs = `forge_session=${createSession(app.db, admin.id).token}`;
116 expect((await post(mine, servers.url)).status).toBe(404);
117 expect((await post(theirs, "https://evil.test")).status).toBe(403);
118 expect(getPortalUrl(app.db)).toBe(before);
119 expect((await post(theirs, servers.url)).status).toBe(303);
120 expect(getPortalUrl(app.db)).toBe(PORTAL);
121 });
122
123 test("/settings shows the Plan row to an admin and to a member not at all", async () => {
124 await writeFile(join(dir, "parent.json"), JSON.stringify({ upstream: "https://host.example" }));
125 expect(keepNotice(dir, { name: "Medium", size: "medium", priceUsd: 49, creditUsd: 10, status: "active", portalUrl: PORTAL })).toBe(true);
126 const get = async (id: string) => (await fetch(`${servers.url}/settings`, { headers: { cookie: `forge_session=${createSession(app.db, id).token}` } })).text();
127 const seen = await get(admin.id);
128 expect(seen).toContain("<dt>Plan</dt>");
129 expect(seen).toContain(`href="${PORTAL}"`);
130 expect(await get(member.id)).not.toContain("<dt>Plan</dt>");
131 });
test/spawn.test.ts +2
@@ -93,6 +93,8 @@ describe("spawnForge", () => {
9393 // The plan's included credit, lent once (#11154).
9494 credit: { capUsd: 2, spentUsd: 0, token: expect.stringMatching(/^kid\./) },
9595 });
96 // Its own plan, told once when it was made (#11141): a file of its own, never parent.json.
97 expect(JSON.parse(await Bun.file(join(child, "plan.json")).text())).toMatchObject({ name: "Small", size: "small", status: "active" });
9698 });
9799
98100 test("no secret arrives", async () => {
test/tui-plan-row.test.ts added +87
@@ -0,0 +1,87 @@
1 /**
2 * The Plan row in the terminal (#11141): an admin reads the plan line and
3 * sets the billing portal through the page's own action; a member is not
4 * offered the screen.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm, writeFile } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App as Forge } from "../src/app.ts";
11 import { keepNotice } from "../src/billing/notice.ts";
12 import { createUser, getUserByName } from "../src/core/users.ts";
13 import { getPortalUrl } from "../src/settings/billing.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import type { TuiIdentity } from "../src/tui/session.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let forge: Forge;
24
25 function boot(username: string) {
26 const user = getUserByName(forge.db, username)!;
27 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
28 const { app } = createForgeApp(forge, identity, { terminal: headlessTerminal(140, 32), exitHooks: false });
29 app.start();
30 const k = (key: string) => app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
31 return {
32 text: () => app.screen.toText(),
33 press: async (...keys: string[]) => {
34 keys.forEach((x) => k(x));
35 await settleApp(app);
36 },
37 open: async () => {
38 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
39 for (const ch of "Plan") k(ch);
40 k("enter");
41 await settleApp(app);
42 },
43 stop: () => app.stop(),
44 };
45 }
46
47 beforeAll(async () => {
48 dir = await mkdtemp(join(tmpdir(), "forge-tui-plan-"));
49 forge = createApp({ dataDir: dir, port: 0 });
50 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
51 const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst });
52 if (!made.ok) throw new Error(made.error);
53 }
54 await writeFile(join(dir, "parent.json"), JSON.stringify({ upstream: "https://host.example" }));
55 keepNotice(dir, { name: "Medium", size: "medium", priceUsd: 49, creditUsd: 10, status: "grace", periodEnd: 1_900_000_000 });
56 });
57 afterAll(async () => {
58 forge.close();
59 await rm(dir, { recursive: true, force: true });
60 });
61
62 test("an admin reads the plan line and sets the portal; a bad address keeps nothing", async () => {
63 const h = boot("alice");
64 await h.open();
65 expect(h.text()).toContain("Medium");
66 expect(h.text()).toContain("Past due, sleeps 2030-");
67 await h.press("enter");
68 for (const ch of "http://insecure.test") await h.press(ch);
69 await h.press("enter");
70 expect(getPortalUrl(forge.db)).toBe("");
71 await h.press("esc");
72 h.stop();
73 const again = boot("alice");
74 await again.open();
75 await again.press("enter");
76 for (const ch of "https://billing.test/login") await again.press(ch);
77 await again.press("enter");
78 expect(getPortalUrl(forge.db)).toBe("https://billing.test/login");
79 again.stop();
80 });
81
82 test("a member is not offered the screen", async () => {
83 const h = boot("bob");
84 await h.open();
85 expect(h.text()).not.toContain("Billing portal");
86 h.stop();
87 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs