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

scout/forge:scout/11138-pricing-sells-a-forge-pick-a-plan-and-a → main 17 commits fork 1 thread · 1 unresolved

  • Merged. scout/11138-pricing-sells-a-forge-pick-a-plan-and-a was merged into main as ef7edf1.

Review

Sign in to review this change.
26 files changed +1114 −48
docs/SPAWN.md +11 −1
@@ -72,7 +72,7 @@ Children report in; the parent never reaches in. Every ten minutes a spawned chi
7272
7373 ## Callers
7474
75 `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, size?, template?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `size` is `small` (default), `medium` or `large`; anything else is a 422 on `size`. It is recorded in the plan, the ledger, `parent.json` and `GET /api/v1/forges`, but not enforced yet: it limits nothing. `dryRun` is true unless it is `false`: the answer is the plan, what is left behind, and `firstAdmin` (the caller's username, or `null` for an anonymous plan, where whoever claims the child becomes its admin), and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make.
75 `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, size?, template?, dryRun}` (rw token; a person who is signed in, and a paid plan only from an admin, a gift code, or a paid order below) and the MCP `spawn` tool share `src/spawn/door.ts`. `size` is `small` (default), `medium` or `large`; anything else is a 422 on `size`. It is recorded in the plan, the ledger, `parent.json` and `GET /api/v1/forges`, but not enforced yet: it limits nothing. `dryRun` is true unless it is `false`: the answer is the plan, what is left behind, and `firstAdmin` (the caller's username, or `null` for an anonymous plan, where whoever claims the child becomes its admin), and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make.
7676
7777 A spawn may also name a `size`: `small`, `medium` or `large` (`src/spawn/sizes.ts` maps each to CPU, memory and disk). It is recorded in the plan only when given, so a plan without one hashes as it always did, and an unknown size is a 422 on `size`. The New forge page and the terminal form both offer it, small preselected and not sent; the page shows a refusal beside the select, linked with `aria-describedby`. Nothing enforces the limits yet, and the table's numbers are provisional. `GET /api/v1/forges/sizes` (no sign-in, ETag, cached for 5 minutes) and the MCP tool `sizes` give the same answer — each size's CPU, memory and disk, read from that table, and one sentence on what it is for — so a page or an agent offering a size reads it there.
7878
@@ -196,3 +196,13 @@ ledger (`spawn/sizes.ts`). **Unset means no quota.** A spawn that would pass a
196196 declared limit is a 409 `refused` with one sentence naming it, and the New
197197 forge page shows that sentence above the form when even a small forge would
198198 not fit (`spawn/quota.ts`).
199
200 ## The paid path: `/pricing`
201
202 A visitor with no account buys a forge. `GET /pricing` (public) lists the plans from the setting with their size, price and crew credit. A paid plan's button posts the forge's name and an email to `POST /pricing/order`, which checks the name is free (not a user, a child, a reserved word or another order's hold), writes an **order** (`forge_order`: name, plan, email, state `pending`) that holds the name for 30 minutes, and redirects to the provider's checkout. The buyer's ticket is an HttpOnly cookie scoped to `/pricing`; only its hash is kept.
203
204 When the order turns paid (the `checkout.session.completed` webhook, or `/pricing/done` asking the provider for the session, whichever is first) one `UPDATE … WHERE state = 'pending'` wins and goes on: `spawnDoor(..., { paid: true })` makes the forge on that plan, the entitlement is written, the order is `spawned`. A second delivery finds the order moved and changes nothing.
205
206 `/pricing/done` waits for the spawn, then mints a fresh claim link and shows it once, as `/forges/new` does; when mail is configured it is also mailed to the order's email. `/pricing/resend` re-sends a new link (the old one stops) to the order's own email only, and answers the same whether or not an order matched.
207
208 A paid order whose spawn fails (no capacity, a name taken in a race) becomes `failed`: a `billing.order_failed` event, the `paid_order` flow alarm, and a plain page to the buyer. On Settings → Billing an admin sees "Paid, not made" with **Retry** and a link to the provider's dashboard to refund. With no provider connected the paid plans show their size and say "ask the operator". Agents are refused as buyers.
docs/design/audit.md +3 −1
@@ -49,6 +49,9 @@ Machine routes are not pages and have no entry: `/audit.csv`,
4949
5050 - `/` — `pages/site.ts` (`home`, studio mode in `studio/home.ts`) · where I am, what needs me · `/front` (same page for anyone), `/board`, `/asks` · colours 3 in `site.ts`, 7 in `studio/home.ts`.
5151 - `/front` — `studio/home.ts` (`studioDoor`) · the signed-out door, and the site admin's controls over it · `/` for a signed-out reader · colours 7.
52 - `/pricing` — `pages/pricing.ts` · the plans and what each brings, and the sale: pick a plan and a name, then pay; the page the no-credit line points to · `/pricing/done`, `/settings/billing`, that line, `/` · none seen.
53 - `/pricing/done` — `pages/pricing.ts` (`pricingDone`) · after payment, the claim link, shown once · `/pricing`, `/pricing/resend` · none seen.
54 - `/pricing/resend` — `pages/pricing.ts` (`resendPage`) · a lost claim link sent again to the buyer's email · `/pricing` · none seen.
5255 - `/explore` — `studio/explore.ts` · browse what the forge holds · `/search`, `/apps` · colours 2.
5356 - `/board` — `pages/board.ts` (1,231 lines) · every app the viewer can see, with a wall mode · `/boards` (saved queries as panels), `/flow`, `/` · colour 1, sizes 6.
5457 - `/board/returns` — `pages/returns.ts` · what the window's dollars bought · `/settings/subscriptions` spend tables, `/settings/usage` · none seen.
@@ -94,7 +97,6 @@ Machine routes are not pages and have no entry: `/audit.csv`,
9497 - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1.
9598 - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1.
9699 - `/privacy` — `pages/privacy.ts` · what the forge keeps about a visitor, three plain lines, open to anyone · none · none seen.
97 - `/pricing` — `pricing.ts` · the plans and what each brings, the page the no-credit line points to · that line, `/` · none seen.
98100 - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen.
99101 - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen.
100102 - `/fleet` — `pages/fleet.ts` · the rows a fleet query line selects, as a table · `/fleet/graph` · none seen.
docs/features/home-plans.md +1 −1
@@ -24,4 +24,4 @@ The home reads who may sign up, the plans and the buy links in one statement (`s
2424
2525 ## When the crew has no credit
2626
27 A forge whose crew has no key and no included credit says so once on its first screen: "The crew cannot build until you add a model key or pick a plan." (`NO_CREDIT_WORDS`, `crewFirstLine` in `src/web/studio/crew_key.ts`; the terminal's first screen says the same line as text, `crewFirstText`, with credit too). The key is a link to `/settings/subscriptions` (admins), the plan a link to `/pricing`, the plans on a page of their own (`src/web/pricing.ts`). With credit the line is the next step: make an app or bring a repository. A tell sent with no credit is kept, and the flash says the crew cannot build yet, with the same two ways forward; that holds for the first screen's own box (`app=forge`, kept as a thought) as for a tell to an app. `/pricing` has no terminal screen: its door there is the palette's *Get your own forge*.
27 A forge whose crew has no key and no included credit says so once on its first screen: "The crew cannot build until you add a model key or pick a plan." (`NO_CREDIT_WORDS`, `crewFirstLine` in `src/web/studio/crew_key.ts`; the terminal's first screen says the same line as text, `crewFirstText`, with credit too). The key is a link to `/settings/subscriptions` (admins), the plan a link to `/pricing`, the plans, and the sale, on a page of their own (`src/web/pages/pricing.ts`). With credit the line is the next step: make an app or bring a repository. A tell sent with no credit is kept, and the flash says the crew cannot build yet, with the same two ways forward; that holds for the first screen's own box (`app=forge`, kept as a thought) as for a tell to an app. `/pricing` has no terminal screen: its door there is the palette's *Get your own forge*.
docs/features/pricing.md added +12
@@ -0,0 +1,12 @@
1 # Buy a forge at /pricing (#11138)
2
3 Open `/pricing` with no account. Each plan shows its size, price and crew credit. Type the forge's name and an email, press the paid plan's button, pay at the provider's checkout, and the page you return to (`/pricing/done`) shows the claim link once. Open it to take the forge over.
4
5 - **Free plan:** its button goes to `/forges/new`.
6 - **Closed the tab?** `/pricing/resend`: name and email you bought with; a new link is mailed there (needs mail configured) and the old one stops.
7 - **No provider connected:** paid plans show their size and say "Ask the operator".
8 - **Operator:** connect Stripe under Settings → Billing. A paid order whose forge could not be made shows under "Paid, not made" with Retry and a link to the provider's dashboard to refund; a flow alarm names it.
9 - **Limits:** one client address may have 3 orders waiting at once (counted by socket address, never a header); a name is held 35 minutes and the provider's checkout stops being payable at 31, so a late payer never finds the name given away; a claim link is mailed again at most once a day per order.
10 - **Terminal:** no screen yet; checkout is browser-only (the provider's hosted page). Tracked in a follow-up issue.
outdated

Name the terminal door here once the src/tui/ pricing screen exists (finding 1), and the per-address pending cap (finding 2).

11 - **Code:** `src/actions/order.ts` (rules), `src/billing/orders.ts` (store), `src/web/pages/pricing.ts` (pages). The paid path is described in `docs/SPAWN.md`.
12 - **Test:** `bun test test/pricing-order.test.ts`.
src/actions/order.ts added +241
@@ -0,0 +1,241 @@
1 /**
2 * The sale (#11138): a visitor picks a paid plan, a name and an email; the
3 * name is held for 30 minutes (an order, `billing/orders.ts`) and the buyer
4 * goes to the provider's checkout. When the order turns paid, by the webhook
5 * or by the return page asking the provider for the session, whichever
6 * comes first, the forge is made once through `spawnDoor` on that plan and
7 * its entitlement written. A paid order whose forge cannot be made is
8 * `failed`: an event, a flow alarm, and a plain sentence to the buyer; an
9 * admin retries it or refunds at the provider. Never an agent as buyer.
10 *
11 * The claim link is shown once, on the return page, and mailed when the
12 * forge can send mail. It is never kept: the page makes a fresh one when the
13 * buyer arrives, and a buyer who left can ask for one by email.
14 */
15
16 import type { App } from "../app.ts";
17 import { checkoutOf } from "../billing/checkout.ts";
18 import { getOrder, holdName, markResent, markShown, move, unmarkShown, pendingFrom, MAX_PENDING_PER_ADDRESS, CHECKOUT_MS, nameHeld, newId, newTicket, dropPending, pendingCount, setSession, spawnedFor, ticketFits, MAX_PENDING, type Order } from "../billing/orders.ts";
19 import { putEntitlement } from "../billing/store.ts";
20 import { getUserByName, type User } from "../core/users.ts";
21 import { publicOrigin } from "../core/public_origin.ts";
22 import { err, ok, type Result } from "../kernel/result.ts";
23 import { isChildName } from "../core/proposal_token.ts";
24 import type { Ctx } from "../http/context.ts";
25 import { spawnDoor } from "../spawn/door.ts";
26 import { FREE, getPlan, type Plan } from "../settings/plans.ts";
27 import { listChildrenNow, reissueClaimFor } from "./spawn.ts";
28 import { mayMail, sendCapped } from "./mail.ts";
29 import { mailerOf } from "./verify.ts";
30
31 export interface Refusal {
32 readonly field: "name" | "email" | "plan" | "form";
33 readonly message: string;
34 }
35
36 const ADDRESS = /^[^\s<>@,;:"\\]{1,64}@[^\s<>@,;:"\\]{1,190}$/;
37 export const NO_PROVIDER = "Paid plans are not on sale here yet: ask the operator.";
38 /** A spawn that stays `spawning` this long is taken as lost and may be retried. */
39 const STALE_MS = 5 * 60_000;
40 /** A claim link is mailed again at most once a day per order: a stranger cannot make it a mail-bomb. */
41 const RESEND_MS = 24 * 60 * 60_000;
42
43 /** A request-less context the forge itself acts through when no visitor is on the line (a webhook). */
44 function systemCtx(app: App): Ctx {
45 const origin = publicOrigin(app.config.url, `http://localhost:${app.config.port}`);
46 return {
47 app,
48 request: new Request(`${origin}/pricing`, { method: "POST" }),
49 url: new URL(`${origin}/pricing`),
50 params: {},
51 auth: { user: undefined, scope: undefined, via: "none" },
52 theme: undefined,
53 };
54 }
55
56 /** The email, trimmed and lower-cased, or undefined. */
57 export const emailOf = (raw: unknown): string | undefined => {
58 const s = typeof raw === "string" ? raw.trim() : "";
59 return s.length <= 254 && ADDRESS.test(s) ? s.toLowerCase() : undefined;
60 };
61
62 /** Every refusal of a name, in one sentence, or undefined when it is free to buy. */
63 export async function nameRefusal(app: App, name: string, plan: Plan, now: number): Promise<string | undefined> {
64 if (!isChildName(name) || name.length > 38) return "Use 1 to 38 lowercase letters, digits and dashes, not starting or ending with a dash";
65 if (getUserByName(app.db, name) !== undefined || listChildrenNow(app).some((c) => c.name === name)) return "That name is taken";
66 if (nameHeld(app.db, name, now)) return "That name is held for someone else; try another";
67 // The door's own plan: reserved words and the rest of the name's rules, with nothing made.
68 const dry = await spawnDoor(systemCtx(app), undefined, { name, apps: [], crew: [], plan: plan.key, dryRun: true }, { paid: true });
69 if (dry.status >= 400) return String(dry.body["message"] ?? "That name cannot be used").replace(/^name: /, "");
70 return undefined;
71 }
72
73 export interface Placed {
74 readonly id: string;
75 /** Goes in the buyer's HttpOnly cookie; only its hash is kept. */
76 readonly ticket: string;
77 /** The provider's checkout address. */
78 readonly redirect: string;
79 }
80
81 /** Validate, hold the name, make the checkout. Nothing is held if the checkout cannot be made. */
82 export async function placeOrder(app: App, input: { name: unknown; plan: unknown; email: unknown; address: string }, url: URL, now = Date.now()): Promise<Result<Placed, Refusal>> {
83 const plan = typeof input.plan === "string" ? getPlan(app.db, input.plan) : undefined;
84 if (plan === undefined || plan.key === FREE || plan.priceUsd <= 0) return err({ field: "plan", message: "Pick one of the paid plans" });
85 const email = emailOf(input.email);
86 if (email === undefined) return err({ field: "email", message: "Enter the email the link should go to" });
87 const name = typeof input.name === "string" ? input.name.trim() : "";
88 const provider = checkoutOf(app);
89 if (provider === undefined) return err({ field: "form", message: NO_PROVIDER });
90 if (pendingCount(app.db, now) >= MAX_PENDING) return err({ field: "form", message: "Many orders are waiting right now; try again in a few minutes" });
91 if (pendingFrom(app.db, input.address, now) >= MAX_PENDING_PER_ADDRESS) return err({ field: "form", message: "You have orders waiting already; finish one or try again in half an hour" });
92 const refused = await nameRefusal(app, name, plan, now);
93 if (refused !== undefined) return err({ field: "name", message: refused });
94 const id = newId();
95 const ticket = newTicket();
96 if (!holdName(app.db, { id, name, plan: plan.key, email, ticket, address: input.address }, now)) return err({ field: "name", message: "That name is held for someone else; try another" });
97 const origin = publicOrigin(app.config.url, url.origin);
98 const made = await provider.create({
99 orderId: id, forge: name, plan: plan.key, planName: plan.name, priceUsd: plan.priceUsd, email,
100 successUrl: `${origin}/pricing/done?order=${id}`, cancelUrl: `${origin}/pricing`,
101 // The checkout stops being payable before the name's hold lapses.
102 expiresAt: Math.floor((now + CHECKOUT_MS) / 1000),
103 });
104 if (!made.ok) {
105 dropPending(app.db, id);
106 app.log.warn("a checkout could not be made", { provider: provider.name, says: made.error });
107 return err({ field: "form", message: "The payment provider could not be reached; nothing was charged. Try again in a minute" });
108 }
109 setSession(app.db, id, made.value.id);
110 app.bus.append({ kind: "billing.order_placed", payload: { id, forge: name, plan: plan.key } });
111 return ok({ id, ticket, redirect: made.value.url });
112 }
113
114 /**
115 * The order is paid: make its forge, once. Called by the webhook and by the
116 * return page; only the call whose move out of `pending` took effect goes on,
117 * and a later call just reads where the order is. Never throws.
118 */
119 export async function settleOrder(app: App, id: string, paid: { customer?: string; subscription?: string } = {}): Promise<Order | undefined> {
120 const now = Date.now();
121 const o = getOrder(app.db, id);
122 if (o === undefined) return undefined;
123 if (!move(app.db, id, "pending", "paid", now, { customerId: paid.customer ?? "", subscriptionId: paid.subscription ?? "" })) return o;
124 app.bus.append({ kind: "billing.order_paid", payload: { id, forge: o.name, plan: o.plan } });
125 return makeForge(app, id, "paid");
126 }
127
128 /** Make the forge of an order standing in `from` (paid, or failed for a retry). */
129 async function makeForge(app: App, id: string, from: "paid" | "failed" | "spawning"): Promise<Order | undefined> {
130 if (!move(app.db, id, from, "spawning", Date.now())) return getOrder(app.db, id);
131 const o = getOrder(app.db, id) as Order;
132 const fail = (reason: string): Order => {
133 move(app.db, id, "spawning", "failed", Date.now(), { reason });
134 app.bus.append({ kind: "billing.order_failed", payload: { id, forge: o.name, plan: o.plan, reason } });
135 app.log.error("a paid order could not be made into a forge", { id, forge: o.name, reason });
136 return getOrder(app.db, id) as Order;
137 };
138 try {
139 const plan = getPlan(app.db, o.plan);
140 if (plan === undefined) return fail(`the ${o.plan} plan is no longer offered`);
141 const door = await spawnDoor(systemCtx(app), undefined, { name: o.name, apps: [], crew: [], plan: plan.key, dryRun: false }, { paid: true });
142 if (door.status >= 400) return fail(String(door.body["message"] ?? "the forge could not be made").slice(0, 300));
143 putEntitlement(app.db, {
144 forge: o.name, plan: plan.key, status: "active", provider: checkoutOf(app)?.name ?? "none", payer: o.email,
145 customerId: o.customerId, subscriptionId: o.subscriptionId, source: checkoutOf(app)?.name ?? "paid",
146 }, plan.size, "");
147 move(app.db, id, "spawning", "spawned", Date.now());
148 app.bus.append({ kind: "billing.order_spawned", payload: { id, forge: o.name, plan: plan.key } });
149 return getOrder(app.db, id);
150 } catch (error) {
151 return fail(`the forge could not be made: ${String(error).slice(0, 200)}`);
152 }
153 }
154
155 /** An event from the provider says this session was paid: settle its order, if it is one of ours. */
156 export async function settleSession(app: App, session: { id?: unknown; metadata?: { order?: unknown } | null; customer?: unknown; subscription?: unknown; payment_status?: unknown }): Promise<Order | undefined> {
157 if (session.payment_status !== "paid" && session.payment_status !== "no_payment_required") return undefined;
158 const id = typeof session.metadata?.order === "string" ? session.metadata.order : undefined;
159 const sid = typeof session.id === "string" ? session.id : "";
160 const o = id === undefined ? undefined : getOrder(app.db, id);
161 // The session must be the one this order made: a made-up order id on another session settles nothing.
162 if (o === undefined || sid === "" || o.sessionId !== sid) return undefined;
163 const text = (v: unknown): string => (typeof v === "string" && /^[A-Za-z0-9_]{1,100}$/.test(v) ? v : "");
164 return settleOrder(app, o.id, { customer: text(session.customer), subscription: text(session.subscription) });
165 }
166
167 /** The return page's reconcile: a pending order is asked of the provider, and settled if it was paid. */
168 export async function reconcileOrder(app: App, id: string): Promise<Order | undefined> {
169 const o = getOrder(app.db, id);
170 if (o === undefined) return undefined;
171 if (o.state === "spawning" && Date.now() - o.updatedAt > STALE_MS) return makeForge(app, id, "spawning");
172 if (o.state !== "pending" || o.sessionId === "") return o;
173 const provider = checkoutOf(app);
174 if (provider === undefined) return o;
175 const seen = await provider.state(o.sessionId);
176 if (!seen.ok || !seen.value.paid) return o;
177 return settleOrder(app, id, { customer: seen.value.customer, subscription: seen.value.subscription });
178 }
179
180 export type Delivered =
181 | { readonly kind: "link"; readonly claimUrl: string; readonly mailed: boolean }
182 | { readonly kind: "shown" }
183 | { readonly kind: "claimed" }
184 | { readonly kind: "failed" };
185
186 const mailOf = (name: string, link: string) => ({
187 subject: `Your forge ${name} is ready`,
188 text: ["Your forge is ready.", "", "Open this link to take it over. It works once, for a day:", "", link, "", "If you did not buy a forge, ignore this message."].join("\n"),
189 });
190
191 /** Send the link to the order's email when the forge can mail; true when it went. */
192 async function mailLink(app: App, o: Order, link: string): Promise<boolean> {
193 const mailer = mailerOf(app, { id: "order" });
194 if (mailer === undefined || !mayMail(app, o.email)) return false;
195 const m = mailOf(o.name, link);
196 return (await sendCapped(app, mailer, { to: o.email, ...m })) === "sent";
197 }
198
199 /** A spawned order's claim link, for the buyer who holds its ticket: made fresh, shown once, mailed when mail is on. */
200 export async function deliverLink(app: App, o: Order): Promise<Delivered> {
201 if (o.state !== "spawned") return { kind: "failed" };
202 if (!markShown(app.db, o.id, Date.now())) return { kind: "shown" };
203 const made = await reissueClaimFor(app, o.name);
204 if (!made.ok) {
205 unmarkShown(app.db, o.id);
206 return "reason" in made.error && made.error.reason === "claimed" ? { kind: "claimed" } : { kind: "failed" };
207 }
208 return { kind: "link", claimUrl: made.value.claimUrl, mailed: await mailLink(app, o, made.value.claimUrl) };
209 }
210
211 /** A buyer who left asks for the link again: it goes to the order's own email and nowhere else. Always the same answer. */
212 export async function resendLink(app: App, nameIn: unknown, emailIn: unknown, now = Date.now()): Promise<"sent" | "no_mail" | "asked"> {
213 if (mailerOf(app, { id: "order" }) === undefined) return "no_mail";
214 const name = typeof nameIn === "string" ? nameIn.trim() : "";
215 const email = emailOf(emailIn);
216 const o = email === undefined || !isChildName(name) ? undefined : spawnedFor(app.db, name, email);
217 // One a day per order, and only to the address it was bought with.
218 if (o === undefined || (o.resentAt !== null && now - o.resentAt < RESEND_MS)) return "asked";
219 const made = await reissueClaimFor(app, o.name);
220 if (!made.ok) return "asked";
221 markResent(app.db, o.id, now);
222 await mailLink(app, o, made.value.claimUrl);
223 return "asked";
224 }
225
226 export type RetryError = "not_found" | "not_failed";
227
228 /** An admin who is a person retries a failed (or lost) spawn of a paid order. */
229 export async function retryOrder(app: App, actor: User | undefined, id: unknown): Promise<Result<Order, RetryError>> {
230 if (actor?.isAdmin !== true || actor.kind !== "user") return err("not_found");
231 const o = typeof id === "string" ? getOrder(app.db, id) : undefined;
232 if (o === undefined) return err("not_found");
233 const stale = o.state === "spawning" && Date.now() - o.updatedAt > STALE_MS;
234 if (o.state !== "failed" && !stale) return err("not_failed");
235 app.bus.append({ kind: "billing.order_retried", actorId: actor.id, payload: { id: o.id, forge: o.name } });
236 const made = await makeForge(app, o.id, stale ? "spawning" : "failed");
237 return ok(made ?? o);
238 }
239
240 /** The ticket a cookie carries fits this order. */
241 export const mayView = (app: App, id: string, ticket: string): boolean => ticketFits(app.db, id, ticket);
src/actions/spawn.ts +15 −2
@@ -288,7 +288,7 @@ async function make(
288288 if (plan.plan !== undefined && chosen === undefined)
289289 return err({ kind: "invalid", field: "plan", reason: `${plan.plan} is not a plan this forge offers` });
290290 if (chosen !== undefined && chosen.key !== FREE && deps.gift === true) {
291 // A redeemed gift code (`actions/codes.ts`) is what lends this plan: it asks no admin and no payment.
291 // A redeemed gift code (`actions/codes.ts`) or a paid order (`actions/order.ts`) is what lends this plan: it asks no admin.
292292 } else if (chosen !== undefined && chosen.key !== FREE) {
293293 if (owner === undefined) return err({ kind: "refused", reason: "plan", message: `the ${chosen.name} plan is for a signed-in person; sign in first` });
294294 if (!owner.isAdmin) return err({ kind: "refused", reason: "plan", message: PAID_PLAN_ADMIN_ONLY(chosen.name) });
@@ -677,6 +677,20 @@ export async function reissueClaim(
677677 const user = requireUser(actor);
678678 if (!user.ok) return err({ kind: "unauthorized" });
679679 if (!user.value.isAdmin) return err({ kind: "forbidden" });
680 const made = await reissueClaimFor(app, name);
681 if (made.ok) app.bus.append({ kind: "forge.claim_reissued", actorId: user.value.id, payload: { name } });
682 return made;
683 }
684
685 /**
686 * A new claim link for an unclaimed child, with no actor: the caller has
687 * already decided who may have it (an admin above; the buyer of the forge in
688 * `actions/order.ts`). The old link stops working.
689 */
690 export async function reissueClaimFor(
691 app: App,
692 name: string,
693 ): Promise<Result<{ readonly name: string; readonly claimUrl: string }, SpawnError | { readonly kind: "not_found" }>> {
680694 const ledger = /^[a-z0-9-]{1,39}$/.test(name) ? await readLedger(app, name) : undefined;
681695 if (!ledger || !ledger.done) return err({ kind: "not_found" });
682696 const child = createApp({ dataDir: childDirOf(app, ledger.bodyId) });
@@ -686,7 +700,6 @@ export async function reissueClaim(
686700 if (hasAdmin(child.db))
687701 return err({ kind: "refused", reason: "claimed", message: `${name} is already claimed; ask its owner to invite you` });
688702 const token = mintClaim(child.db, spawned.org.id);
689 app.bus.append({ kind: "forge.claim_reissued", actorId: user.value.id, payload: { name } });
690703 return ok({ name, claimUrl: claimLink(liveChildUrl(app, name), token) });
691704 } finally {
692705 child.close();
src/actions/verify.ts +1 −1
@@ -56,7 +56,7 @@ export function mailConfigured(app: App): boolean {
5656 return MAIL_SECRETS.every((name) => set.has(name));
5757 }
5858
59 function mailerOf(app: App, forUser: User): Mailer | undefined {
59 export function mailerOf(app: App, forUser: { readonly id: string }): Mailer | undefined {
6060 if (app.config.mailer !== undefined) return app.config.mailer;
6161 const stored = resolveSecrets(
6262 app.db,
src/billing/checkout.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * The checkout seam (#11138): what the sale needs of a payment provider,
3 * and nothing else. `create` makes a hosted checkout for one order and
4 * returns where to send the buyer; `state` asks whether a session was paid
5 * (the return page reconciles with it when the webhook is late). The
6 * provider is Stripe when it is connected (`billing/connection.ts`), else
7 * `none`: nothing is sold and the pricing page says so. A test swaps in its
8 * own with `useCheckout`.
9 */
10
11 import type { App } from "../app.ts";
12 import { err, ok, type Result } from "../kernel/result.ts";
13 import { connectionOf, openKey } from "./connection.ts";
14 import { stripeClient, type CheckoutRequest, type CheckoutSession, type CheckoutState } from "./stripe.ts";
15
16 export type { CheckoutRequest, CheckoutSession, CheckoutState };
17
18 export interface CheckoutProvider {
19 readonly name: string;
20 create(req: CheckoutRequest): Promise<Result<CheckoutSession, string>>;
21 state(sessionId: string): Promise<Result<CheckoutState, string>>;
22 }
23
24 const swapped = new WeakMap<App, CheckoutProvider>();
25
26 /** Use `provider` for this app; for tests. */
27 export function useCheckout(app: App, provider: CheckoutProvider | undefined): void {
28 if (provider === undefined) swapped.delete(app);
29 else swapped.set(app, provider);
30 }
31
32 /** The provider this forge sells through now, or undefined when none is connected. */
33 export function checkoutOf(app: App): CheckoutProvider | undefined {
34 const test = swapped.get(app);
35 if (test !== undefined) return test;
36 if (connectionOf(app.db) === undefined) return undefined;
37 const open = openKey(app.db, app.config.dataDir, app.bus, "checkout");
38 if (open === undefined) return undefined;
39 const client = stripeClient(open.key);
40 return {
41 name: "stripe",
42 async create(req) {
43 const r = await client.createCheckout(req);
44 return r.ok ? r : err(r.error.message);
45 },
46 async state(id) {
47 const r = await client.getCheckout(id);
48 return r.ok ? ok(r.value) : err(r.error.message);
49 },
50 };
51 }
52
53 /** Where the operator pays back a buyer: the provider's own dashboard. */
54 export const REFUND_URL = "https://dashboard.stripe.com/payments";
src/billing/orders.ts added +160
@@ -0,0 +1,160 @@
1 /**
2 * The store of orders (#11138): a visitor's purchase of a forge, from the
3 * name being held to the forge being made. Plain rows in, plain rows out;
4 * the checkout and the spawn are in `actions/order.ts`.
5 *
6 * States: `pending` (name held for 30 minutes, nothing paid), `paid`
7 * (money taken, forge not yet made), `spawning`, `spawned`, `failed` (paid,
8 * the forge could not be made: an admin's alarm). A move is one UPDATE that
9 * names the state it leaves, so two deliveries of the same payment cannot
10 * both make the forge: only the one whose UPDATE changed a row goes on.
11 * The buyer's ticket (a cookie) is kept as its sha256; the claim link is
12 * never kept.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import { sha256 } from "../kernel/hash.ts";
17
18 export type OrderState = "pending" | "paid" | "spawning" | "spawned" | "failed";
19
20 /** The hold outlives the checkout (`CHECKOUT_MS`) so a buyer who pays at the last minute still finds the name theirs. */
21 export const HOLD_MS = 35 * 60_000;
22 /** How long the provider's checkout stays payable; Stripe's least is 30 minutes. */
23 export const CHECKOUT_MS = 31 * 60_000;
24 /** Most orders waiting for payment at once: a name-hoarder cannot hold the whole namespace. */
25 export const MAX_PENDING = 200;
26 /** Most orders one client address may have waiting at once: one stranger cannot fill the shared cap. */
27 export const MAX_PENDING_PER_ADDRESS = 3;
28
29 export interface Order {
30 readonly id: string;
31 readonly name: string;
32 readonly plan: string;
33 readonly email: string;
34 readonly state: OrderState;
35 readonly sessionId: string;
36 readonly customerId: string;
37 readonly subscriptionId: string;
38 readonly reason: string;
39 readonly createdAt: number;
40 readonly holdUntil: number;
41 readonly updatedAt: number;
42 readonly shownAt: number | null;
43 readonly resentAt: number | null;
44 }
45
46 interface Row {
47 id: string; name: string; plan: string; email: string; state: OrderState; ticket_hash: string; session_id: string;
48 customer_id: string; subscription_id: string; reason: string; created_at: number; hold_until: number; updated_at: number;
49 shown_at: number | null; resent_at: number | null;
50 }
51
52 const fromRow = (r: Row): Order => ({
53 id: r.id, name: r.name, plan: r.plan, email: r.email, state: r.state, sessionId: r.session_id, customerId: r.customer_id,
54 subscriptionId: r.subscription_id, reason: r.reason, createdAt: r.created_at, holdUntil: r.hold_until, updatedAt: r.updated_at,
55 shownAt: r.shown_at, resentAt: r.resent_at,
56 });
57
58 export const newId = (): string => `ord_${crypto.randomUUID().replace(/-/g, "")}`;
59 export const newTicket = (): string => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("hex");
60
61 export function getOrder(db: Database, id: string): Order | undefined {
62 const r = db.query<Row, [string]>("SELECT * FROM forge_order WHERE id = ?1").get(id);
63 return r ? fromRow(r) : undefined;
64 }
65
66 export function bySession(db: Database, sessionId: string): Order | undefined {
67 if (sessionId === "") return undefined;
68 const r = db.query<Row, [string]>("SELECT * FROM forge_order WHERE session_id = ?1").get(sessionId);
69 return r ? fromRow(r) : undefined;
70 }
71
72 /** True when the ticket is the one the order was made with. */
73 export function ticketFits(db: Database, id: string, ticket: string): boolean {
74 const r = db.query<{ ticket_hash: string }, [string]>("SELECT ticket_hash FROM forge_order WHERE id = ?1").get(id);
75 return r !== null && ticket !== "" && r.ticket_hash === sha256(ticket);
76 }
77
78 /** Whether an order other than a lapsed pending one holds `name` now. */
79 export function nameHeld(db: Database, name: string, now: number): boolean {
80 return (
81 db
82 .query<{ n: number }, [string, number]>(
83 "SELECT COUNT(*) AS n FROM forge_order WHERE name = ?1 AND (state != 'pending' OR hold_until > ?2)",
84 )
85 .get(name, now)?.n !== 0
86 );
87 }
88
89 /** Orders waiting for payment from one client address (`http/peer.ts` `clientBucket`, never a header). */
90 export const pendingFrom = (db: Database, address: string, now: number): number =>
91 db.query<{ n: number }, [string, number]>("SELECT COUNT(*) AS n FROM forge_order WHERE state = 'pending' AND hold_until > ?2 AND address = ?1").get(address, now)?.n ?? 0;
92
93 export const pendingCount = (db: Database, now: number): number =>
94 db.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM forge_order WHERE state = 'pending' AND hold_until > ?1").get(now)?.n ?? 0;
95
96 /** Write a new pending order. The hold is checked and the row written in one transaction. False when the name is held. */
97 export function holdName(db: Database, o: { id: string; name: string; plan: string; email: string; ticket: string; address: string }, now: number): boolean {
98 return db.transaction(() => {
99 if (nameHeld(db, o.name, now)) return false;
100 db.query(
101 `INSERT INTO forge_order (id, name, plan, email, state, ticket_hash, created_at, hold_until, updated_at, address)
102 VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?6, ?8)`,
103 ).run(o.id, o.name, o.plan, o.email, sha256(o.ticket), now, now + HOLD_MS, o.address);
104 return true;
105 })();
106 }
107
108 export function setSession(db: Database, id: string, sessionId: string): void {
109 db.query("UPDATE forge_order SET session_id = ?2 WHERE id = ?1").run(id, sessionId);
110 }
111
112 /** Drop a pending order whose checkout could not be made: its name is free again. */
113 export function dropPending(db: Database, id: string): void {
114 db.query("DELETE FROM forge_order WHERE id = ?1 AND state = 'pending'").run(id);
115 }
116
117 /** Move `from` to `to`; true only for the caller whose statement changed the row. */
118 export function move(db: Database, id: string, from: OrderState, to: OrderState, now: number, extra: { reason?: string; customerId?: string; subscriptionId?: string } = {}): boolean {
119 return (
120 db
121 .query(
122 `UPDATE forge_order SET state = ?3, updated_at = ?4, reason = ?5,
123 customer_id = CASE WHEN ?6 = '' THEN customer_id ELSE ?6 END,
124 subscription_id = CASE WHEN ?7 = '' THEN subscription_id ELSE ?7 END
125 WHERE id = ?1 AND state = ?2`,
126 )
127 .run(id, from, to, now, extra.reason ?? "", extra.customerId ?? "", extra.subscriptionId ?? "").changes === 1
128 );
129 }
130
131 /** The buyer has been shown the link: true once, for the caller that marks it. */
132 export function markShown(db: Database, id: string, now: number): boolean {
133 return db.query("UPDATE forge_order SET shown_at = ?2 WHERE id = ?1 AND shown_at IS NULL").run(id, now).changes === 1;
134 }
135
136 /** Give the link back: a mint that failed leaves the buyer able to see it on the next visit. */
137 export function unmarkShown(db: Database, id: string): void {
138 db.query("UPDATE forge_order SET shown_at = NULL WHERE id = ?1").run(id);
139 }
140
141 export function markResent(db: Database, id: string, now: number): void {
142 db.query("UPDATE forge_order SET resent_at = ?2 WHERE id = ?1").run(id, now);
143 }
144
145 /** The newest spawned order for a name and email, for a re-send. */
146 export function spawnedFor(db: Database, name: string, email: string): Order | undefined {
147 const r = db
148 .query<Row, [string, string]>("SELECT * FROM forge_order WHERE name = ?1 AND lower(email) = ?2 AND state = 'spawned' ORDER BY created_at DESC LIMIT 1")
149 .get(name, email.toLowerCase());
150 return r ? fromRow(r) : undefined;
151 }
152
153 /** Orders an admin must look at: paid and not made. Newest first. */
154 export function failedOrders(db: Database): Order[] {
155 return db.query<Row, []>("SELECT * FROM forge_order WHERE state = 'failed' ORDER BY updated_at DESC LIMIT 50").all().map(fromRow);
156 }
157
158 export function recentOrders(db: Database, limit = 20): Order[] {
159 return db.query<Row, [number]>("SELECT * FROM forge_order WHERE state != 'pending' ORDER BY updated_at DESC LIMIT ?1").all(limit).map(fromRow);
160 }
src/billing/stripe.ts +57
@@ -63,7 +63,34 @@ export function formBody(data: { [k: string]: Value }): string {
6363 return parts.join("&");
6464 }
6565
66 export interface CheckoutRequest {
67 readonly orderId: string;
68 readonly forge: string;
69 readonly plan: string;
70 readonly planName: string;
71 readonly priceUsd: number;
72 readonly email: string;
73 readonly successUrl: string;
74 readonly cancelUrl: string;
75 /** Unix seconds after which the checkout cannot be paid (Stripe: 30 minutes to 24 hours from now). */
76 readonly expiresAt?: number;
77 }
78
79 export interface CheckoutSession {
80 readonly id: string;
81 readonly url: string;
82 }
83
84 /** What the provider says of a session: paid or not, and who is billed. */
85 export interface CheckoutState {
86 readonly paid: boolean;
87 readonly customer: string;
88 readonly subscription: string;
89 }
90
6691 export interface StripeClient {
92 createCheckout(req: CheckoutRequest): Promise<Result<CheckoutSession, StripeError>>;
93 getCheckout(sessionId: string): Promise<Result<CheckoutState, StripeError>>;
6794 addWebhook(url: string): Promise<Result<{ id: string; secret: string }, StripeError>>;
6895 removeWebhook(id: string): Promise<Result<true, StripeError>>;
6996 /** The subscription as Stripe has it now. */
@@ -112,6 +139,36 @@ export function stripeClient(key: string, fetchFn: typeof fetch = fetch): Stripe
112139 const missing = (what: string): StripeError => ({ kind: "rejected", status: 502, message: `Stripe's answer had no ${what}.` });
113140
114141 return {
142 async createCheckout(req) {
143 const r = await call("POST", "/v1/checkout/sessions", {
144 mode: "subscription",
145 success_url: req.successUrl,
146 cancel_url: req.cancelUrl,
147 customer_email: req.email,
148 client_reference_id: req.orderId,
149 ...(req.expiresAt === undefined ? {} : { expires_at: req.expiresAt }),
150 line_items: [{
151 quantity: 1,
152 price_data: {
153 currency: "usd",
154 unit_amount: Math.round(req.priceUsd * 100),
155 recurring: { interval: "month" },
156 product_data: { name: `${req.planName} forge` },
157 },
158 }],
159 metadata: { order: req.orderId, forge: req.forge, plan: req.plan },
160 }, req.orderId);
161 if (!r.ok) return r;
162 const id = str(r.value, "id"), url = str(r.value, "url");
163 return id && url ? ok({ id, url }) : err(missing("checkout address"));
164 },
165 async getCheckout(sessionId) {
166 if (!/^cs_[A-Za-z0-9_]{1,200}$/.test(sessionId)) return err({ kind: "rejected", status: 400, message: "That is not a checkout session id." });
167 const r = await call("GET", `/v1/checkout/sessions/${sessionId}`);
168 if (!r.ok) return r;
169 const paid = r.value["payment_status"] === "paid" || r.value["payment_status"] === "no_payment_required";
170 return ok({ paid, customer: str(r.value, "customer"), subscription: str(r.value, "subscription") });
171 },
115172 async addWebhook(url) {
116173 const r = await call("POST", "/v1/webhook_endpoints", { url, enabled_events: [...WEBHOOK_EVENTS] },
117174 // fresh per attempt: Stripe replays an answer for a repeated key for 24h, and a reconnect must not get a deleted endpoint back
src/core/migration-pins/20261111140000-forge-orders.pin added +1
@@ -0,0 +1,1 @@
1 8f2099c03e05b76718444c78944bd349fb68145cc46e6bc5fde57f091dc11693
src/core/migration-pins/20261111150000-forge-order-address.pin added +1
@@ -0,0 +1,1 @@
1 fa5d0fb138f7a201bf62e538c21efe4220855230305b3827fb9a08756b056da1
src/core/migrations/20261111140000-forge-orders.ts added +24
@@ -0,0 +1,24 @@
1 // Orders (#11138): a visitor's purchase of a forge. One row from the moment the name is held (pending)
2 // to the forge being made (spawned) or failing (failed, kept for an admin). The buyer's ticket is kept
3 // only as a sha256; the claim link is never kept. Additive.
4 export default `
5 CREATE TABLE forge_order (
6 id TEXT PRIMARY KEY,
7 name TEXT NOT NULL,
8 plan TEXT NOT NULL,
9 email TEXT NOT NULL,
10 state TEXT NOT NULL,
11 ticket_hash TEXT NOT NULL,
12 session_id TEXT NOT NULL DEFAULT '',
13 customer_id TEXT NOT NULL DEFAULT '',
14 subscription_id TEXT NOT NULL DEFAULT '',
15 reason TEXT NOT NULL DEFAULT '',
16 created_at INTEGER NOT NULL,
17 hold_until INTEGER NOT NULL,
18 updated_at INTEGER NOT NULL,
19 shown_at INTEGER,
20 resent_at INTEGER
21 );
22 CREATE INDEX forge_order_name ON forge_order (name);
23 CREATE INDEX forge_order_state ON forge_order (state);
24 `;
src/core/migrations/20261111150000-forge-order-address.ts added +6
@@ -0,0 +1,6 @@
1 // Orders (#11138): the client address an order was placed from, so one address cannot hold more than
2 // its share of pending names. Additive; older rows keep the empty address.
3 export default `
4 ALTER TABLE forge_order ADD COLUMN address TEXT NOT NULL DEFAULT '';
5 CREATE INDEX forge_order_address ON forge_order (address, state);
6 `;
src/flow/alarm_paid_order.ts added +24
@@ -0,0 +1,24 @@
1 /**
2 * The "paid, not made" flow alarm (#11138): someone paid for a forge and it
3 * could not be made (no room, the name taken in a race). Fires from the
4 * moment the order failed until an admin retries it into a forge. Names the
5 * oldest, so the one who has waited longest is the one shown.
6 */
7
8 import type { Database } from "bun:sqlite";
9
10 export interface PaidOrderAlarm {
11 readonly fired: boolean;
12 readonly since?: string;
13 readonly says: string;
14 }
15
16 export function alarmPaidOrder(db: Database): PaidOrderAlarm {
17 const rows = db
18 .query<{ name: string; reason: string; updated_at: number }, []>("SELECT name, reason, updated_at FROM forge_order WHERE state = 'failed' ORDER BY updated_at ASC")
19 .all();
20 const first = rows[0];
21 if (first === undefined) return { fired: false, says: "Every paid order has its forge." };
22 const more = rows.length > 1 ? ` (and ${rows.length - 1} more)` : "";
23 return { fired: true, since: new Date(first.updated_at).toISOString(), says: `A buyer paid for ${first.name} and it could not be made: ${first.reason}${more}.` };
24 }
src/flow/alarms.ts +4 −1
@@ -14,6 +14,7 @@ import { ownRepoPath } from "../kernel/own_repo.ts";
1414 import { bugClockAlarm } from "./bugs.ts";
1515 import { alarmBackup } from "./alarm_backup.ts";
1616 import { alarmFromApp } from "./alarm_from_app.ts";
17 import { alarmPaidOrder } from "./alarm_paid_order.ts";
1718 import { alarmIdleCrew } from "./alarm_idle_crew.ts";
1819 import { noReleaseAlarm } from "./alarm_no_release.ts";
1920 import { redMainAlarm } from "./alarm_red_main.ts";
@@ -22,7 +23,7 @@ import { alarmSilent } from "./alarm_silent.ts";
2223 import { alarmStuck } from "./alarm_stuck.ts";
2324
2425 export interface FiredAlarm {
25 readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable" | "backup";
26 readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable" | "backup" | "paid_order";
2627 readonly says: string;
2728 readonly since: string;
2829 /** A same-origin path to the item the alarm names. */
@@ -62,6 +63,8 @@ export function firedAlarms(
6263 if (fromApp.fired) out.push({ key: "from_app", says: fromApp.says, since: fromApp.since!, href: `/${fromApp.repo}/issues/${fromApp.number}` });
6364 const backup = opts.dataDir === null ? undefined : alarmBackup(opts.dataDir, now);
6465 if (backup?.fired) out.push({ key: "backup", says: backup.says, since: backup.since!, href: "/flow" });
66 const paidOrder = alarmPaidOrder(db);
67 if (paidOrder.fired) out.push({ key: "paid_order", says: paidOrder.says, since: paidOrder.since!, href: "/settings/billing" });
6568 const idle = alarmIdleCrew(db, now, { ceilingUsd: opts.ceilingUsd ?? null });
6669 if (idle.fired) out.push({ key: "idle_crew", says: idle.says, since: idle.since!, href: "/boards" });
6770 }
src/http/server.ts +6 −2
@@ -35,7 +35,6 @@ import { maintenanceDuty, maintenancePage, maintenancePrepare, maintenanceResume
3535 import { maintenanceBudget } from "../web/pages/maintenance_budgets.ts";
3636 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
3737 import { agentStartSet, billingLinksSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
38 import { pricingPage } from "../web/pricing.ts";
3938 import { plansSet } from "../web/settings/plans.ts";
4039 import { allFlowsPage } from "../web/pages/flows-all.ts";
4140 import { measureSubmit } from "./measure.ts";
@@ -281,6 +280,7 @@ import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, fo
281280 forgeOwnCodeSubmit, forgesPage } from "../web/pages/forges.ts";
282281 import { forgeItSubmit } from "../web/pages/forge-it.ts";
283282 import { funnelSubmit } from "./funnel.ts";
283 import { pricingDone, pricingOrder, pricingPage, resendPage, resendSubmit } from "../web/pages/pricing.ts";
284284 import { forgeWaitingPage } from "../web/pages/forge-waiting.ts";
285285 import { startForgeSubmit } from "../web/pages/start-forge.ts";
286286 import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts";
@@ -858,6 +858,11 @@ export function buildRouter(): Router<Handler> {
858858 router.add("GET", "/forges/:name/remove", forgeRemoveConfirm);
859859 router.add("POST", "/forges/:name/remove", forgeRemoveSubmit);
860860 router.add("GET", "/forges/:name/ready", forgeReady);
861 router.add("GET", "/pricing", pricingPage);
862 router.add("POST", "/pricing/order", pricingOrder);
863 router.add("GET", "/pricing/done", pricingDone);
864 router.add("GET", "/pricing/resend", resendPage);
865 router.add("POST", "/pricing/resend", resendSubmit);
861866 router.add("GET", "/forges/:name/waiting", forgeWaitingPage);
862867 router.add("GET", "/forges/new", newForgePage);
863868 router.add("POST", "/forges/new", newForgeSubmit);
@@ -1021,7 +1026,6 @@ export function buildRouter(): Router<Handler> {
10211026 router.add("GET", "/live/moving", liveMovingFragment);
10221027 router.add("GET", "/live/chips", liveChipsFragment);
10231028 router.add("GET", "/feed", feedPage);
1024 router.add("GET", "/pricing", pricingPage);
10251029 // The home's late regions, each drawn alone (#7660).
10261030 for (const name of HOME_REGIONS) router.add("GET", `/live/home/${name}`, (ctx) => liveHomeFragment(ctx, name));
10271031 // Whether a hostname is an app's verified domain — what Caddy's
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 } from "../web/pages/billing.ts";
21 import { billingPage, billingGrant, billingSetLinks, 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";
@@ -215,6 +215,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
215215 router.add("POST", "/settings/billing/stripe/connect", stripeConnect);
216216 router.add("POST", "/settings/billing/stripe/secret", stripeSecret);
217217 router.add("POST", "/settings/billing/stripe/disconnect", stripeDisconnect);
218 router.add("POST", "/settings/billing/orders/retry", orderRetry);
218219 router.add("POST", "/settings/billing/codes", billingMakeCode);
219220 router.add("POST", "/settings/billing/codes/revoke", billingRevokeCode);
220221 router.add("GET", "/settings/flags", flagsPage);
src/http/stripe_hook.ts +9
@@ -13,6 +13,7 @@
1313 */
1414
1515 import type { App } from "../app.ts";
16 import { settleSession } from "../actions/order.ts";
1617 import { bindCheckout } from "../billing/checkout_completed.ts";
1718 import { openWebhookSecret } from "../billing/connection.ts";
1819 import { syncSubscription } from "../billing/sync.ts";
@@ -119,6 +120,14 @@ export async function stripeHook(ctx: Ctx): Promise<Response> {
119120 if (stored.changes === 1 && type === "checkout.session.completed") bindCheckout(app, event);
120121 return stored.changes === 1;
121122 })();
123 // A paid checkout that is one of our orders makes its forge (once, whoever sees the payment first), then binds it to the customer.
124 if (fresh && type === "checkout.session.completed") {
125 const session = (event as { data?: { object?: unknown } }).data?.object;
126 if (session !== null && typeof session === "object") {
127 const settled = await settleSession(app, session as Parameters<typeof settleSession>[1]);
128 if (settled?.state === "spawned") bindCheckout(app, event);
129 }
130 }
122131 if (fresh && (type.startsWith("customer.subscription.") || type.startsWith("invoice."))) {
123132 try {
124133 await syncSubscription(app, subscriptionIdOf(event));
src/spawn/door.ts +10 −7
@@ -181,12 +181,15 @@ export async function spawnDoor(
181181 ctx: Ctx,
182182 user: User | undefined,
183183 raw: Record<string, unknown>,
184 /** `paid`: the plan was bought (`actions/order.ts`), so a paid plan needs no admin and a stranger's rate limits do not apply. */
185 opts: { readonly paid?: boolean } = {},
184186 ): Promise<Door> {
187 const paid = opts.paid === true;
185188 const anon = user === undefined;
186189 if (!anon && user.kind !== "user") return spawnWords({ kind: "forbidden" });
187190 // A plan is free of children but not of work — it reads every catalog
188191 // app it names — so a stranger's are counted per address and per forge.
189 if (anon) {
192 if (anon && !paid) {
190193 const verdict = takeDoor(ctx.app, "plan", ctx.request);
191194 if (!verdict.allowed)
192195 return {
@@ -282,8 +285,8 @@ export async function spawnDoor(
282285 if (raw["plan"] !== undefined && raw["plan"] !== null && raw["plan"] !== "") {
283286 const chosen = typeof raw["plan"] === "string" ? getPlan(db, raw["plan"]) : undefined;
284287 if (chosen === undefined) return invalid("plan", `must be one of ${getPlans(db).map((p) => p.key).join(", ")}`);
285 if (anon && chosen.key !== FREE && giftIn === undefined) return invalid("plan", `the ${chosen.name} plan is for a signed-in person; sign in first`);
286 if (chosen.key !== FREE && user?.isAdmin !== true && giftIn === undefined) return invalid("plan", PAID_PLAN_ADMIN_ONLY(chosen.name));
288 if (anon && chosen.key !== FREE && giftIn === undefined && !paid) return invalid("plan", `the ${chosen.name} plan is for a signed-in person; sign in first`);
289 if (chosen.key !== FREE && user?.isAdmin !== true && giftIn === undefined && !paid) return invalid("plan", PAID_PLAN_ADMIN_ONLY(chosen.name));
287290 if (size !== undefined && size !== null && size !== "" && size !== chosen.size)
288291 return invalid("size", `the ${chosen.name} plan is a ${chosen.size} forge; leave size out or send ${chosen.size}`);
289292 planKey = chosen.key;
@@ -325,7 +328,7 @@ export async function spawnDoor(
325328 };
326329 }
327330 // A stranger may start only a few children a day; the plan above is free.
328 if (anon) {
331 if (anon && !paid) {
329332 const limit = checkAnonSpawn(ctx.app.db, ctx.request);
330333 if (!limit.allowed)
331334 return {
@@ -349,7 +352,7 @@ export async function spawnDoor(
349352 let made: Awaited<ReturnType<typeof spawnForge>>;
350353 try {
351354 made = await spawnForge(ctx.app, user, input, source, {
352 ...(gift === undefined ? {} : { gift: true }),
355 ...(gift === undefined && !paid ? {} : { gift: true }),
353356 placement: placementFor(ctx.app),
354357 request: ctx.request,
355358 url: ctx.url,
@@ -364,9 +367,9 @@ export async function spawnDoor(
364367 if (gift !== undefined && !settled) unclaimGift(ctx.app, gift);
365368 }
366369 if (!made.ok) return spawnWords(made.error);
367 if (anon && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request);
370 if (anon && !paid && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request);
368371 // The visitor who started (or was given) this forge becomes someone, on the host's side (#11265).
369 if (!made.value.existed) linkRequestVisitor(ctx.app, ctx.request, made.value.name, gift === undefined ? "started" : "paid");
372 if (!made.value.existed && !paid) linkRequestVisitor(ctx.app, ctx.request, made.value.name, gift === undefined ? "started" : "paid");
370373 // Its apps come up now, so the first visit after a claim finds them awake.
371374 if (!made.value.existed) {
372375 void bootHostedChild(ctx.app, made.value.name);
src/web/layout.ts +1
@@ -732,6 +732,7 @@ function footer(ctx: Ctx, own?: Html): Html {
732732 >`}
733733 <span class="spacer"></span>
734734 <a href="/docs">Docs</a>
735 <a href="/pricing">Pricing</a>
735736 <a href="/changelog">Changelog</a>
736737 <a href="/api">API</a>
737738 ${ctx.auth.user !== undefined && ctx.auth.user.kind !== "org"
src/web/pages/billing.ts +28 −1
@@ -7,6 +7,9 @@
77
88 import { ledBlock } from "./visitors.ts";
99 import { BILLING_WORDS, STRIPE_WORDS, connectStripe, disconnectStripe, grantPlan, readBilling, saveSigningSecret, type BillingError, type StripeWords } from "../../actions/billing.ts";
10 import { retryOrder } from "../../actions/order.ts";
11 import { REFUND_URL } from "../../billing/checkout.ts";
12 import { failedOrders } from "../../billing/orders.ts";
1013 import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts";
1114 import { publicOrigin } from "../../core/public_origin.ts";
1215 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
@@ -186,7 +189,21 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {
186189 ${btn("Connect")}
187190 </form>`}
188191 </section>`;
189 const body = html`${stripe}<section class="settings-block">
192 const failed = failedOrders(ctx.app.db);
193 const orders = failed.length === 0 ? null : html`<section class="settings-block" id="orders" data-orders>
194 <h2>Paid, not made</h2>
195 <p class="muted small">These buyers paid and their forge could not be made. Retry the spawn, or pay them back at the provider.</p>
196 <ul class="list setting-list">${failed.map((o) => html`<li class="row">
197 <form class="row-fields" method="post" action="${PATH}/orders/retry">
198 <input type="hidden" name="id" value="${o.id}" />
199 <span class="mono">${o.name}</span>
200 <span class="muted small">${o.plan} · ${o.email} · ${o.reason}</span>
201 ${btn("Retry")}
202 <a href="${REFUND_URL}" rel="noopener noreferrer" target="_blank">Refund at provider</a>
203 </form>
204 </li>`)}</ul>
205 </section>`;
206 const body = html`${stripe}${orders}<section class="settings-block">
190207 <h2>Plans</h2>
191208 <p class="muted small">Choose the plan each forge is on.</p>
192209 ${rows.length === 0
@@ -271,3 +288,13 @@ export async function stripeDisconnect(ctx: Ctx): Promise<Response> {
271288 const r = await disconnectStripe(ctx.app, ctx.auth.user);
272289 return back(ctx, r.ok ? { ok: true, message: "Stripe is disconnected." } : r);
273290 }
291
292 // POST /settings/billing/orders/retry — an admin who is a person retries a paid order whose forge was not made.
293 export async function orderRetry(ctx: Ctx): Promise<Response> {
294 if (!isPerson(ctx)) return notFoundPage(ctx);
295 const form = await readForm(ctx, "admin");
296 if (form instanceof Response) return form;
297 const r = await retryOrder(ctx.app, ctx.auth.user, field(form, "id"));
298 if (!r.ok) return r.error === "not_found" ? notFoundPage(ctx) : flashTo(ctx, PATH, { tone: "danger", message: "That order is not waiting for a retry." });
299 return flashTo(ctx, PATH, r.value.state === "spawned" ? { message: `${r.value.name} is made.` } : { tone: "danger", message: `${r.value.name} still could not be made: ${r.value.reason}.` });
300 }
src/web/pages/pricing.ts added +202
@@ -0,0 +1,202 @@
1 /**
2 * `/pricing` (#11138): the sale. A visitor with no account picks a plan, names
3 * the forge, gives an email and pays at the provider's checkout; a minute
4 * later `/pricing/done` shows the claim link, once. The free plan goes on to
5 * `/forges/new`. With no provider connected the paid plans show their size and
6 * say "ask the operator". The rules are in `actions/order.ts`.
7 */
8
9 import { deliverLink, mayView, NO_PROVIDER, placeOrder, reconcileOrder, resendLink, type Refusal } from "../../actions/order.ts";
10 import { checkoutOf } from "../../billing/checkout.ts";
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import { cookieValue, type Ctx } from "../../http/context.ts";
13 import { secureFor } from "../../http/cookie_secure.ts";
14 import { clientBucket } from "../../http/peer.ts";
15 import { takeDoor } from "../../http/strangers.ts";
16 import { FREE, getPlans, type Plan } from "../../settings/plans.ts";
17 import { sizeWords } from "../../settings/billing.ts";
18 import { childHost } from "../../spawn/door.ts";
19 import { notFoundPage, pageHead } from "../chrome.ts";
20 import { field, readForm } from "../form.ts";
21 import { html, type Html } from "../html.ts";
22 import { page, redirect } from "../layout.ts";
23 import { btn, field as formField, row } from "../ui.ts";
24 import { claimLinkBody } from "./new-forge.ts";
25
26 const COOKIE = "forge_order";
27 const REFRESH_JS = "setTimeout(function(){location.reload()},3000);";
28
29 interface Values {
30 readonly name: string;
31 readonly email: string;
32 }
33
34 const isBuyer = (ctx: Ctx): boolean => ctx.auth.user === undefined || ctx.auth.user.kind === "user";
35
36 function plansView(ctx: Ctx, values: Values, errors: Partial<Record<Refusal["field"], string>>): Html {
37 const selling = checkoutOf(ctx.app) !== undefined;
38 const plans = getPlans(ctx.app.db);
39 const rows = plans.map((p: Plan) => {
40 const free = p.key === FREE;
41 const paid = !free && selling;
42 return row({
43 title: html`${p.name}`,
44 meta: html`${sizeWords(p.size)}. ${p.says}${p.creditUsd > 0 && selling ? html` $${p.creditUsd} of crew credit each month.` : null}`,
45 trail: free
46 ? html`<span class="muted">Free</span> ${btn("Start free", { name: "plan", value: p.key })}`
47 : paid
48 ? html`<span>$${p.priceUsd} a month</span> ${btn(`Get ${p.name}`, { name: "plan", value: p.key, variant: "primary" })}`
49 : html`<span class="muted">Ask the operator</span>`,
50 data: { plan: p.key },
51 });
52 });
53 return html`<div class="narrow-wide" id="pricing" data-pricing>
54 ${pageHead("Get your own forge", { sub: selling ? "Pick a plan, name it, pay. Your forge is ready a minute later." : "Pick a size and name it." })}
55 ${errors["form"] ? html`<p class="error" role="alert">${errors["form"]}</p>` : null}
56 <form method="post" action="/pricing/order" class="stack" id="order">
57 ${formField({
58 label: "Name",
59 name: "name",
60 value: values.name,
61 required: true,
62 hint: `Its address: <name>--${childHost(ctx.app)}.`,
63 ...(errors["name"] ? { error: errors["name"] } : {}),
64 })}
65 ${selling
66 ? formField({
67 label: "Email",
68 name: "email",
69 type: "email",
70 value: values.email,
71 autocomplete: "email",
72 hint: "The link to open your forge is shown after payment and sent here.",
73 ...(errors["email"] ? { error: errors["email"] } : {}),
74 })
75 : null}
76 <ul class="list setting-list">${rows}</ul>
77 </form>
78 <p class="muted small">Paid already? <a href="/pricing/done">Open your order</a> · <a href="/pricing/resend">Lost your link?</a></p>
79 </div>`;
80 }
81
82 // GET /pricing — public; agents are refused as buyers.
83 export function pricingPage(ctx: Ctx): Response {
84 if (!isBuyer(ctx)) return notFoundPage(ctx);
85 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
86 const rendering = performance.now();
87 const response = page({ ctx, title: "Get your own forge", body: plansView(ctx, { name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), email: "" }, {}) });
88 notePhase(ctx.request, "render", performance.now() - rendering);
89 return response;
90 }
91
92 const cookieFor = (ctx: Ctx, value: string, maxAge: number): string =>
93 `${COOKIE}=${value}; Path=/pricing; Max-Age=${maxAge}; SameSite=Lax; HttpOnly${secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : ""}`;
94
95 // POST /pricing/order — free goes on to the free path; a paid plan holds the name and goes to checkout.
96 export async function pricingOrder(ctx: Ctx): Promise<Response> {
97 if (!isBuyer(ctx)) return notFoundPage(ctx);
98 const form = await readForm(ctx, ctx.auth.user === undefined ? undefined : "admin");
99 if (form instanceof Response) return form;
100 const values: Values = { name: field(form, "name").trim().slice(0, 60), email: field(form, "email").trim().slice(0, 254) };
101 const plan = field(form, "plan");
102 if (plan === FREE) return redirect(`/forges/new?name=${encodeURIComponent(values.name.slice(0, 38))}`);
103 const verdict = takeDoor(ctx.app, "plan", ctx.request);
104 if (!verdict.allowed) return page({ ctx, title: "Get your own forge", body: plansView(ctx, values, { form: `Too many tries from here; try again in ${verdict.retryAfter}s` }) }, 429);
105 const placed = await placeOrder(ctx.app, { name: values.name, plan, email: values.email, address: clientBucket(ctx.request) }, ctx.url);
106 if (!placed.ok) {
107 const status = placed.error.message === NO_PROVIDER ? 409 : 422;
108 return page({ ctx, title: "Get your own forge", body: plansView(ctx, values, { [placed.error.field]: `${placed.error.message}.` }) }, status);
109 }
110 const res = redirect(placed.value.redirect, { cookie: cookieFor(ctx, `${placed.value.id}.${placed.value.ticket}`, 2 * 86_400) });
111 res.headers.set("cache-control", "no-store");
112 return res;
113 }
114
115 /** The order the cookie names, when the address names the same one. */
116 function mine(ctx: Ctx): string | undefined {
117 const id = ctx.url.searchParams.get("order") ?? "";
118 const [cid, ticket] = (cookieValue(ctx.request, COOKIE) ?? "").split(".");
119 if (cid !== id || ticket === undefined || !/^ord_[0-9a-f]{32}$/.test(id) || !mayView(ctx.app, id, ticket)) return undefined;
120 return id;
121 }
122
123 const waiting = (ctx: Ctx, says: string): Response => {
124 const res = page({
125 ctx,
126 title: "Making your forge",
127 scriptJs: REFRESH_JS,
128 body: html`<div class="narrow stack" data-order="waiting">${pageHead("Making your forge")}<p role="status">${says}</p><p class="small"><a href="${ctx.url.pathname}${ctx.url.search}">Check now</a></p></div>`,
129 });
130 res.headers.set("cache-control", "no-store");
131 return res;
132 };
133
134 // GET /pricing/done?order= — reconciles the order with the provider, waits for the forge, shows the link once.
135 export async function pricingDone(ctx: Ctx): Promise<Response> {
136 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
137 const rendering = performance.now();
138 const response = await doneView(ctx);
139 notePhase(ctx.request, "render", performance.now() - rendering);
140 return response;
141 }
142
143 async function doneView(ctx: Ctx): Promise<Response> {
144 if (!isBuyer(ctx)) return notFoundPage(ctx);
145 const id = mine(ctx);
146 if (id === undefined) return notFoundPage(ctx);
147 const o = await reconcileOrder(ctx.app, id);
148 if (o === undefined) return notFoundPage(ctx);
149 const plain = (title: string, body: Html, status = 200): Response => {
150 const res = page({ ctx, title, body: html`<div class="narrow-wide" data-order="${o.state}">${pageHead(title)}<section class="settings-block">${body}</section></div>` }, status);
151 res.headers.set("cache-control", "no-store");
152 return res;
153 };
154 if (o.state === "pending") return waiting(ctx, "Waiting for the payment to arrive. This page updates by itself.");
155 if (o.state === "paid" || o.state === "spawning") return waiting(ctx, `Paid. Making ${o.name} now; the link to open it appears here.`);
156 if (o.state === "failed")
157 return plain(`${o.name} is paid but not made yet`, html`<p>Your payment went through, but the forge could not be made just now: ${o.reason}.</p>
158 <p>The operator has been told and will make it or pay you back. Keep this page's address: <code class="mono">${o.id}</code>.</p>`, 200);
159 const given = await deliverLink(ctx.app, o);
160 if (given.kind === "link")
161 return plain(`${o.name} is ready`, html`${claimLinkBody(given.claimUrl, false)}${given.mailed ? html`<p class="small mt">It was also sent to ${o.email}.</p>` : null}`);
162 if (given.kind === "claimed") return plain(`${o.name} is taken over`, html`<p>The link was used; sign in at <a href="${`https://${o.name}--${childHost(ctx.app)}`}">your forge</a>.</p>`);
163 return plain(`${o.name} is ready`, html`<p>The link was shown already, once.</p><p class="mt">${btn("Send it again by email", { href: "/pricing/resend", size: "sm" })}</p>`);
164 }
165
166 function resendView(ctx: Ctx, says: string | undefined, values: Values): Html {
167 return html`<div class="narrow stack" data-resend>
168 ${pageHead("Lost your link?")}
169 ${says === undefined ? null : html`<p role="status">${says}</p>`}
170 <form method="post" action="/pricing/resend" class="stack">
171 ${formField({ label: "Name of your forge", name: "name", value: values.name, required: true })}
172 ${formField({ label: "Email you bought with", name: "email", type: "email", value: values.email, required: true, hint: "A new link goes there, and the old one stops working." })}
173 ${btn("Send the link", { variant: "primary" })}
174 </form>
175 </div>`;
176 }
177
178 // GET /pricing/resend
179 export function resendPage(ctx: Ctx): Response {
180 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
181 if (!isBuyer(ctx)) return notFoundPage(ctx);
182 const rendering = performance.now();
183 const response = page({ ctx, title: "Lost your link?", body: resendView(ctx, undefined, { name: "", email: "" }) });
184 notePhase(ctx.request, "render", performance.now() - rendering);
185 return response;
186 }
187
188 // POST /pricing/resend — the link goes to the order's own email; the answer is the same whether or not an order matched.
189 export async function resendSubmit(ctx: Ctx): Promise<Response> {
190 if (!isBuyer(ctx)) return notFoundPage(ctx);
191 const form = await readForm(ctx, ctx.auth.user === undefined ? undefined : "admin");
192 if (form instanceof Response) return form;
193 const values: Values = { name: field(form, "name").trim().slice(0, 60), email: field(form, "email").trim().slice(0, 254) };
194 const verdict = takeDoor(ctx.app, "plan", ctx.request);
195 if (!verdict.allowed) return page({ ctx, title: "Lost your link?", body: resendView(ctx, `Too many tries from here; try again in ${verdict.retryAfter}s.`, values) }, 429);
196 const out = await resendLink(ctx.app, values.name, values.email);
197 const says = out === "no_mail" ? "This forge cannot send email. Ask the operator for a new link." : "If that order exists, the link is on its way to the email it was bought with.";
198 const res = page({ ctx, title: "Lost your link?", body: resendView(ctx, says, { name: "", email: "" }) });
199 res.headers.set("cache-control", "no-store");
200 return res;
201 }
202
src/web/pricing.ts deleted −29
@@ -1,29 +0,0 @@
1 /**
2 * GET /pricing: the plans on a page of their own, so a line that says "pick a
3 * plan" (the first screen of a forge whose crew has no credit, #11240) has
4 * somewhere to point. The words are the home's (`plans_row.ts`); the terminal's
5 * door is the palette's "Get your own forge".
6 */
7
8 import { notePhase, takeSessionTime, timePhase } from "../core/route_timing.ts";
9 import type { Ctx } from "../http/context.ts";
10 import { GET_A_FORGE_PATH } from "../billing/plan_words.ts";
11 import { getDoorSettings } from "../settings/door.ts";
12 import { publicPlans } from "../settings/plans.ts";
13 import { freePlanName } from "./funnel_door.ts";
14 import { html } from "./html.ts";
15 import { page } from "./layout.ts";
16 import { plansRow } from "./plans_row.ts";
17 import { btn } from "./ui.ts";
18
19 export function pricingPage(ctx: Ctx): Response {
20 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
21 const { plans, links } = getDoorSettings(ctx.app.db);
22 const body = html`<section id="pricing" class="stack">
23 <h1>Plans</h1>
24 <p class="muted">Each plan comes with crew credit. Or add your own model key and the crew builds on it.</p>
25 ${plansRow(publicPlans(plans), links)}
26 <div class="bar" data-plan="${freePlanName(plans)}">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary" })}</div>
27 </section>`;
28 return timePhase(ctx.request, "render", () => page({ ctx, title: "Plans", body }));
29 }
test/forge-looks.test.ts +1 −1
@@ -20,6 +20,7 @@ quietLogs();
2020
2121 /** Top-level routes that are not pages, or are not worth a picture, and why. */
2222 const NOT_A_PAGE: Record<string, string> = {
23 "/pricing": "no picture: the cap of 24 looks is spent; the page is covered by test/pricing-order.test.ts",
2324 "/audit": "no picture: its slot went to /pinned; /audit.csv is its download, covered by test/audit.test.ts",
2425 "/deploy-to": "a form for a forge admin that fills from its link; covered by test/pinned-page.test.ts",
2526 "/settings": "a form of fields; its slot went to the open help of /boards and /settings/crew (#11170); covered by test/account-help.test.ts",
@@ -45,7 +46,6 @@ const NOT_A_PAGE: Record<string, string> = {
4546 "/opensearch.xml": "the browser search description, XML",
4647 "/sw.js": "the service worker script",
4748 "/audit.csv": "a CSV download of /audit",
48 "/pricing": "no picture: the cap of 24 looks is spent; the page is covered by test/next-steps-card.test.ts",
4949 "/maintenance": "no picture: the cap of 24 looks is spent; an admin's page, covered by test/maintenance.test.ts",
5050 "/claim": "answers 404 to an admin: it exists only for a link-holder",
5151 "/first-admin": "the setup form for a forge with no admin yet",
test/pricing-order.test.ts added +240
@@ -0,0 +1,240 @@
1 /** /pricing (#11138): order -> paid -> spawned -> claim link; duplicate delivery makes one forge; a failed spawn raises the alarm. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { placeOrder, reconcileOrder, resendLink, retryOrder, settleSession } from "../src/actions/order.ts";
9 import { useCheckout, type CheckoutProvider } from "../src/billing/checkout.ts";
10 import { bindCheckout } from "../src/billing/checkout_completed.ts";
11 import { CHECKOUT_MS, getOrder, HOLD_MS, MAX_PENDING_PER_ADDRESS } from "../src/billing/orders.ts";
12 import { getStored } from "../src/billing/store.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { firedAlarms } from "../src/flow/alarms.ts";
15 import { alarmPaidOrder } from "../src/flow/alarm_paid_order.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { ok } from "../src/kernel/result.ts";
18 import { bootHostedChild } from "../src/spawn/host.ts";
19 import { usePlacement, useProbe } from "../src/spawn/door.ts";
20
21 let dir: string;
22 let app: App;
23 let servers: Servers;
24 let base: string;
25 let admin: { id: string } & Record<string, any>;
26 let full = false;
27 let placed = 0;
28 const paidSessions = new Set<string>();
29 const made: string[] = [];
30
31 const fake: CheckoutProvider = {
32 name: "fake",
33 async create(req) {
34 const id = `cs_fake_${req.orderId.slice(4, 16)}`;
35 return ok({ id, url: `https://pay.fake.test/${id}` });
36 },
37 async state(id) {
38 return ok({ paid: paidSessions.has(id), customer: "cus_1", subscription: "sub_1" });
39 },
40 };
41
42 beforeAll(async () => {
43 dir = await mkdtemp(join(tmpdir(), "forge-pricing-"));
44 app = createApp({ dataDir: dir, port: 0, forgeRepo: "alice/forge-code" });
45 servers = await serve(app);
46 base = servers.url;
47 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
48 if (!a.ok) throw new Error("user");
49 admin = a.value as any;
50 const code = await createRepo(app, a.value, { name: "forge-code", visibility: "public" });
51 if (!code.ok) throw new Error("code repo");
52 await commitToBranch(app, a.value, code.value, { message: "v1", files: [{ path: "src/main.ts", content: "console.log('forge')" }] });
53 useProbe(app, () => async () => true);
54 usePlacement(app, {
55 place: async (plan) => (full ? { ok: false, error: { kind: "refused", reason: "capacity", message: "no room" } } as any : ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` })),
56 retire: async () => ok(),
57 });
58 useCheckout(app, fake);
59 }, 30_000);
60
61 afterAll(async () => {
62 for (const n of made) await bootHostedChild(app, n);
63 servers.stop();
64 app.close();
65 await rm(dir, { recursive: true, force: true });
66 });
67
68 const form = (o: Record<string, string>) => ({ method: "POST", redirect: "manual" as const, headers: { "content-type": "application/x-www-form-urlencoded", origin: base }, body: new URLSearchParams(o).toString() });
69 async function buy(name: string, plan = "small") {
70 const res = await fetch(`${base}/pricing/order`, form({ name, plan, email: "[email protected]" }));
71 const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
72 const order = app.db.query<{ id: string; session_id: string }, [string]>("SELECT id, session_id FROM forge_order WHERE name = ?1 ORDER BY created_at DESC").get(name);
73 return { res, cookie, order };
74 }
75 const completed = (o: { id: string; session_id: string }) => ({ id: o.session_id, metadata: { order: o.id }, customer: "cus_1", subscription: "sub_1", payment_status: "paid" });
76
77 describe("/pricing", () => {
78 test("shows the plans with sizes and prices to a stranger, and a way to the free path", async () => {
79 const res = await fetch(`${base}/pricing`);
80 expect(res.status).toBe(200);
81 const text = await res.text();
82 expect(text).toContain("$19 a month");
83 expect(text).toContain("Start free");
84 expect(res.headers.get("content-security-policy")).toBeTruthy();
85 });
86
87 test("the free plan goes to /forges/new", async () => {
88 const res = await fetch(`${base}/pricing/order`, form({ name: "gratis", plan: "free", email: "" }));
89 expect(res.status).toBe(303);
90 expect(res.headers.get("location")).toContain("/forges/new");
91 });
92
93 test("a bad name, a bad email and a cross-origin post are refused; nothing is held", async () => {
94 expect((await fetch(`${base}/pricing/order`, form({ name: "Bad Name", plan: "small", email: "[email protected]" }))).status).toBe(422);
95 expect((await fetch(`${base}/pricing/order`, form({ name: "fine", plan: "small", email: "nope" }))).status).toBe(422);
96 const evil = form({ name: "fine", plan: "small", email: "[email protected]" });
97 evil.headers.origin = "https://evil.test";
98 expect((await fetch(`${base}/pricing/order`, evil)).status).toBeGreaterThanOrEqual(400);
99 expect(app.db.query("SELECT 1 FROM forge_order").all()).toEqual([]);
100 });
101
102 test("order -> webhook paid -> spawned -> the claim link once; a duplicate delivery makes one forge", async () => {
103 const { res, cookie, order } = await buy("shop");
104 expect(res.status).toBe(303);
105 expect(res.headers.get("location")).toStartWith("https://pay.fake.test/");
106 expect(cookie).toStartWith("forge_order=");
107 expect(res.headers.get("set-cookie")).toContain("HttpOnly");
108 expect(getOrder(app.db, order!.id)?.state).toBe("pending");
109 // The name is held for someone else.
110 const other = await fetch(`${base}/pricing/order`, form({ name: "shop", plan: "small", email: "[email protected]" }));
111 expect(other.status).toBe(422);
112 // Two deliveries at once.
113 await Promise.all([settleSession(app, completed(order!)), settleSession(app, completed(order!))]);
114 await settleSession(app, completed(order!));
115 made.push("shop");
116 expect(getOrder(app.db, order!.id)?.state).toBe("spawned");
117 expect(placed).toBe(1);
118 expect(getStored(app.db, "shop")?.plan).toBe("small");
119 const done = await fetch(`${base}/pricing/done?order=${order!.id}`, { headers: { cookie } });
120 const text = await done.text();
121 expect(text).toContain("shop is ready");
122 expect(text).toMatch(/claim|token=|#/i);
123 const again = await fetch(`${base}/pricing/done?order=${order!.id}`, { headers: { cookie } });
124 expect(await again.text()).toContain("shown already");
125 // Someone without the buyer's cookie sees nothing.
126 expect((await fetch(`${base}/pricing/done?order=${order!.id}`)).status).toBe(404);
127 });
128
129 test("the return page reconciles by session id when no webhook came, once", async () => {
130 const { order, cookie } = await buy("lates");
131 paidSessions.add(order!.session_id);
132 const [a, b] = await Promise.all([reconcileOrder(app, order!.id), reconcileOrder(app, order!.id)]);
133 made.push("lates");
134 expect([a?.state, b?.state].includes("spawned")).toBe(true);
135 expect(getOrder(app.db, order!.id)?.state).toBe("spawned");
136 expect(placed).toBe(2);
137 const done = await fetch(`${base}/pricing/done?order=${order!.id}`, { headers: { cookie } });
138 expect(await done.text()).toContain("lates is ready");
139 });
140
141 test("an event for a session that is not the order's settles nothing", async () => {
142 const { order } = await buy("forged");
143 expect(await settleSession(app, { ...completed(order!), id: "cs_other" })).toBeUndefined();
144 expect(getOrder(app.db, order!.id)?.state).toBe("pending");
145 });
146
147 test("a paid order whose spawn fails raises the alarm, tells the buyer, and an admin retries it", async () => {
148 full = true;
149 const { order, cookie } = await buy("nospace");
150 await settleSession(app, completed(order!));
151 expect(getOrder(app.db, order!.id)?.state).toBe("failed");
152 const alarm = alarmPaidOrder(app.db);
153 expect(alarm.fired).toBe(true);
154 expect(alarm.says).toContain("nospace");
155 const done = await fetch(`${base}/pricing/done?order=${order!.id}`, { headers: { cookie } });
156 expect(await done.text()).toContain("paid but not made yet");
157 expect(firedAlarms(app.db, new Date(), { dataDir: null } as any).some((f) => f.key === "paid_order")).toBe(true);
158 // A non-admin cannot retry; an admin can once there is room.
159 expect((await retryOrder(app, undefined, order!.id)).ok).toBe(false);
160 full = false;
161 const retried = await retryOrder(app, admin as any, order!.id);
162 made.push("nospace");
163 expect(retried.ok && retried.value.state).toBe("spawned");
164 expect(alarmPaidOrder(app.db).fired).toBe(false);
165 });
166
167 test("with no provider the paid plans say ask the operator and a post builds nothing", async () => {
168 useCheckout(app, undefined);
169 const text = await (await fetch(`${base}/pricing`)).text();
170 expect(text).toContain("Ask the operator");
171 expect(text).not.toContain("$19 a month");
172 expect((await fetch(`${base}/pricing/order`, form({ name: "nope", plan: "small", email: "[email protected]" }))).status).toBe(409);
173 useCheckout(app, fake);
174 });
175 });
176
177 describe("/pricing: abuse and lifetimes", () => {
178 const url = () => new URL(`${base}/pricing/order`);
179
180 test("one address cannot hold more than its share of names; another address still can", async () => {
181 for (let i = 0; i < MAX_PENDING_PER_ADDRESS; i++) {
182 const r = await placeOrder(app, { name: `hoard${i}`, plan: "small", email: "[email protected]", address: "203.0.113.7" }, url());
183 expect(r.ok).toBe(true);
184 }
185 const over = await placeOrder(app, { name: "hoardx", plan: "small", email: "[email protected]", address: "203.0.113.7" }, url());
186 expect(over.ok).toBe(false);
187 expect(app.db.query("SELECT 1 FROM forge_order WHERE name = 'hoardx'").all()).toEqual([]);
188 const other = await placeOrder(app, { name: "honest", plan: "small", email: "[email protected]", address: "203.0.113.8" }, url());
189 expect(other.ok).toBe(true);
190 });
191
192 test("the checkout stops being payable before the name's hold lapses", async () => {
193 let seen: number | undefined;
194 useCheckout(app, { ...fake, create: async (req) => { seen = req.expiresAt; return fake.create(req); } });
195 const now = Date.now();
196 const r = await placeOrder(app, { name: "latepay", plan: "small", email: "[email protected]", address: "198.51.100.1" }, url(), now);
197 useCheckout(app, fake);
198 expect(r.ok).toBe(true);
199 expect(seen).toBeDefined();
200 // Stripe's least is 30 minutes; the hold is longer than the checkout.
201 expect(seen! * 1000 - now).toBeGreaterThanOrEqual(30 * 60_000);
202 expect(seen! * 1000).toBeLessThan(now + HOLD_MS);
203 expect(CHECKOUT_MS).toBeLessThan(HOLD_MS);
204 });
205
206 test("a claim link is mailed again at most once a day per order", async () => {
207 const { order } = await buy("mailme");
208 await settleSession(app, completed(order!));
209 made.push("mailme");
210 const sent: unknown[] = [];
211 (app.config as any).mailer = async (m: unknown) => { sent.push(m); return { ok: true }; };
212 try {
213 const now = Date.now();
214 await resendLink(app, "mailme", "[email protected]", now);
215 await resendLink(app, "mailme", "[email protected]", now + 11 * 60_000);
216 await resendLink(app, "mailme", "[email protected]", now + 23 * 3_600_000);
217 expect(sent.length).toBe(1);
218 await resendLink(app, "mailme", "[email protected]", now + 25 * 3_600_000);
219 expect(sent.length).toBe(2);
220 } finally {
221 delete (app.config as any).mailer;
222 }
223 });
224
225 test("binding a checkout twice, as the hook does for a spawned order, changes nothing the second time", async () => {
226 const { order } = await buy("twice");
227 const event = { data: { object: { ...completed(order!), mode: "subscription", metadata: { order: order!.id, forge: "twice", plan: "small" } } } };
228 await settleSession(app, event.data.object);
229 made.push("twice");
230 const before = getStored(app.db, "twice");
231 expect(bindCheckout(app, event)).toBe(true);
232 const once = getStored(app.db, "twice");
233 expect(bindCheckout(app, event)).toBe(true);
234 const { updatedAt: _a, ...again } = getStored(app.db, "twice")!;
235 const { updatedAt: _b, ...first } = once!;
236 expect(again).toEqual(first);
237 expect(once?.plan).toBe(before?.plan);
238 expect(once?.customerId).toBe("cus_1");
239 });
240 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs