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

A forge whose billing is `none` sleeps, and a wake shows what happened, a pay-again button and how to export #11485

Merged opened this

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

Merged

mason/forge:mason/11393-a-forge-whose-billing-is-none-sleeps-and → main 5 commits fork 1 thread · 1 unresolved

  • Merged. mason/11393-a-forge-whose-billing-is-none-sleeps-and was merged into main as 0887958.

Review

Sign in to review this change.
11 files changed +453 −4
docs/features/lapsed-forge.md added +17
@@ -0,0 +1,17 @@
1 # A forge whose billing is none
2
3 When a forge's subscription ends and the grace days pass, its billing reads `none` (see [billing](billing.md)). The forge then sleeps: its apps stop answering and are not woken, whoever asks, until it is paid.
4
5 ## What a visitor sees
6
7 Its address answers 402 with one page: a sentence on what happened, a **Pay again** button, and a line that says until when the data is kept and how to ask for an export. A client that does not ask for HTML gets the same sentences as plain text. The button posts to `/pay-again` on that address, same origin only; it makes a checkout for the last paid plan and sends the payer to it. Each address may press it five times a minute, and sixty together; past that the answer is 429 with `Retry-After`.
8
9 These sentences are public: anyone who opens the address reads that the subscription ended and the keep-until date.
10
11 ## Paying
12
13 The provider's webhook records the payment as for any subscription; the next request to the address wakes the forge as it was. Nothing on this page marks a forge paid.
14
15 ## In the terminal
16
17 Palette entry **Lapsed forges** (forge admins who are people): each sleeping forge with the same sentences; `p` starts the checkout and prints the address to hand to the payer. `j`/`k` move, `esc` goes back.
src/billing/lapse_words.ts added +13
@@ -0,0 +1,13 @@
1 /** The sentences a lapsed forge (billing `none`, #11393) is told in, shared by the wake page and the terminal. */
2
3 import type { Lapse } from "./lapse.ts";
4
5 const day = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
6
7 /** What happened, in one sentence. */
8 export const lapsedSentence = (name: string): string =>
9 `${name} is asleep because its subscription ended and was not paid again.`;
10
11 /** How to take the data, in a sentence that names until when it is kept. */
12 export const exportSentence = (lapse: Extract<Lapse, { state: "none" }>, front: string): string =>
13 `Your data is kept until ${day(lapse.keepUntil)}. To take it with you, ask this forge's operator at ${front} for an export of your forge's data.`;
src/billing/pay_again.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * Paying again for a forge whose billing is `none` (#11393): which plan the
3 * checkout asks for and the checkout itself, made through the same provider
4 * seam as a sale (`billing/checkout.ts`). A forge whose subscription was
5 * cancelled sits on the free plan, so the plan asked for is the last paid one
6 * its changes name. Paying is recorded by the provider's webhook as for any
7 * subscription: nothing here marks a forge active.
8 */
9
10 import type { App } from "../app.ts";
11 import { err, ok, type Result } from "../kernel/result.ts";
12 import { FREE, getPlan, type Plan } from "../settings/plans.ts";
13 import { checkoutOf } from "./checkout.ts";
14 import { newId } from "./orders.ts";
15 import { changesOf, getStored } from "./store.ts";
16
17 /** The paid plan this forge pays again for: its own, else the last paid one it had. */
18 export function payAgainPlan(app: App, forge: string): Plan | undefined {
19 const paid = (key: string | undefined): Plan | undefined => {
20 const p = key === undefined || key === FREE ? undefined : getPlan(app.db, key);
21 return p !== undefined && p.priceUsd > 0 ? p : undefined;
22 };
23 const own = paid(getStored(app.db, forge)?.plan);
24 if (own !== undefined) return own;
25 for (const c of changesOf(app.db, forge, 50)) {
26 const p = paid(c.toPlan) ?? paid(c.fromPlan);
27 if (p !== undefined) return p;
28 }
29 return undefined;
30 }
31
32 /** Whether a button to pay again can be offered: a provider is connected and a paid plan is known. */
33 export const canPayAgain = (app: App, forge: string): boolean => checkoutOf(app) !== undefined && payAgainPlan(app, forge) !== undefined;
34
35 /** Make the checkout for `forge`; the address to send the payer to. */
36 export async function startPayAgain(app: App, forge: string, origin: string): Promise<Result<string, string>> {
37 const provider = checkoutOf(app);
38 const plan = payAgainPlan(app, forge);
39 if (provider === undefined || plan === undefined) return err("Paying is not set up on this forge yet; ask its operator");
40 const made = await provider.create({
41 orderId: `again-${newId()}`, forge, plan: plan.key, planName: plan.name, priceUsd: plan.priceUsd,
42 email: getStored(app.db, forge)?.payer ?? "",
43 successUrl: `${origin}/`, cancelUrl: `${origin}/`,
44 expiresAt: Math.floor(Date.now() / 1000) + 3600,
45 });
46 if (!made.ok) {
47 app.log.warn("a checkout to pay again could not be made", { forge, says: made.error });
48 return err("The payment provider could not be reached; nothing was charged. Try again in a minute");
49 }
50 return ok(made.value.url);
51 }
src/http/server.ts +3
@@ -606,6 +606,7 @@ import {
606606 refusedResponse,
607607 socketToCell,
608608 wakeGate,
609 payAgainGate,
609610 watchHostedChildClose,
610611 } from "../spawn/host.ts";
611612 import { getVerifiedDomain } from "../core/domains.ts";
@@ -2652,6 +2653,8 @@ export async function serve(app: App): Promise<Servers> {
26522653 // child's own App before the parent's pages. Ingress routes the label
26532654 // here; forge terminates Host routing in-process.
26542655 {
2656 const paying = await payAgainGate(app, request);
2657 if (paying !== undefined) return securityFloor(paying);
26552658 const gated = wakeGate(app, request);
26562659 if (gated !== undefined) return securityFloor(gated);
26572660 const child = await resolveHostedChild(app, request);
src/http/strangers.ts +3 −3
@@ -23,7 +23,7 @@ import { transferGate } from "../git/env.ts";
2323 import { bucketOf, clientBucket } from "./peer.ts";
2424
2525 /** A door a stranger is counted at, each with its own minute. */
26 export type Door = "api" | "git" | "code" | "plan";
26 export type Door = "api" | "git" | "code" | "plan" | "pay";
2727
2828 export interface StrangerLimits {
2929 /** Requests per minute through each door, per client address. */
@@ -59,8 +59,8 @@ export interface StrangerLimits {
5959 }
6060
6161 export const STRANGER_LIMITS: StrangerLimits = {
62 perAddress: { api: 600, git: 60, code: 10, plan: 10 },
63 forge: { api: 6000, git: 600, code: 60, plan: 60 },
62 perAddress: { api: 600, git: 60, code: 10, plan: 10, pay: 5 },
63 forge: { api: 6000, git: 600, code: 60, plan: 60, pay: 60 },
6464 transfersPerAddress: 4,
6565 transferShare: 0.5,
6666 transferTimeoutMs: 7_200_000,
src/spawn/host.ts +46 −1
@@ -31,6 +31,8 @@ import { floorRefusal } from "./disk_floor.ts";
3131 import { limitValue } from "../settings/limits.ts";
3232 import { awakeRefusal, idleAfterMs, WakeRate } from "./sleep.ts";
3333 import { wakingResponse } from "../web/pages/waking.ts";
34 import { lapsedResponse, payAgainRoute, PAY_AGAIN_PATH } from "../web/pages/lapsed.ts";
35 import { lapseFor, type Lapse } from "../billing/lapse.ts";
3436 import { takeDoor } from "../http/strangers.ts";
3537 import { vouched } from "./vouch.ts";
3638 import { during } from "../perf/jobs.ts";
@@ -256,6 +258,25 @@ export async function sleepChild(parent: App, name: string): Promise<boolean> {
256258 return true;
257259 }
258260
261 /** A child's lapse when its billing is `none`: it sleeps and does not wake until paid (#11393); else undefined. */
262 export function lapsedNone(parent: App, name: string): Extract<Lapse, { state: "none" }> | undefined {
263 const lapse = lapseFor(parent.db, name);
264 return lapse.state === "none" ? lapse : undefined;
265 }
266
267 /** Put to sleep, through `sleepChild`, every awake child whose billing is `none`. Answers the names. */
268 export async function sleepLapsed(parent: App): Promise<string[]> {
269 const names = new Set<string>([...(touched.get(parent)?.values() ?? [])].map((m) => m.name));
270 for (const c of cellChildren(parent).list()) names.add(c.name);
271 const asleep: string[] = [];
272 for (const name of names) {
273 if (lapsedNone(parent, name) === undefined || !(await sleepChild(parent, name))) continue;
274 parent.log.warn("child forge asleep", { name, why: "billing is none" });
275 asleep.push(name);
276 }
277 return asleep;
278 }
279
259280 /** Whether a child is awake: open in this process or running in a cell. */
260281 export function childAwake(parent: App, name: string): boolean {
261282 const ledger = readLedgerSync(parent, name);
@@ -283,7 +304,10 @@ async function closeOne(parent: App, cache: Map<string, App>, bodyId: string, ch
283304 function keepSweeping(parent: App): void {
284305 if (sweepers.has(parent)) return;
285306 const every = Math.max(1000, Math.min(60_000, Math.floor(idleAfterMs() / 4)));
286 const timer = setInterval(() => void during("spawn-idle", () => sleepIdleChildren(parent)).catch(() => {}), every);
307 const timer = setInterval(() => void during("spawn-idle", async () => {
308 await sleepLapsed(parent);
309 await sleepIdleChildren(parent);
310 }).catch(() => {}), every);
287311 timer.unref?.();
288312 sweepers.set(parent, timer);
289313 }
@@ -340,6 +364,12 @@ export function wakeGate(parent: App, request: Request): Response | undefined {
340364 if (isAwakeBody(parent, ledger.bodyId)) void sleepChild(parent, name).catch(() => {});
341365 return refusedResponse(floor);
342366 }
367 // Billing `none`: asleep now and answered with why, never woken until paid (#11393).
368 const lapsed = lapsedNone(parent, name);
369 if (lapsed !== undefined) {
370 if (isAwakeBody(parent, ledger.bodyId)) void sleepChild(parent, name).catch(() => {});
371 return lapsedResponse(parent, request, name, lapsed);
372 }
343373 const known = carriesCredentials(parent, ledger.bodyId, request);
344374 if (isAwakeBody(parent, ledger.bodyId)) {
345375 if (known) {
@@ -374,6 +404,16 @@ export function wakeGate(parent: App, request: Request): Response | undefined {
374404 return undefined;
375405 }
376406
407 /** POST to a lapsed forge's address: the button to pay again. Undefined for anything else. */
408 export async function payAgainGate(parent: App, request: Request): Promise<Response | undefined> {
409 if (request.method !== "POST" || new URL(request.url).pathname !== PAY_AGAIN_PATH) return undefined;
410 const name = parseHostedChildName(request.headers.get("host") ?? "", childHost(parent));
411 if (name === undefined) return undefined;
412 const ledger = readLedgerSync(parent, name);
413 if (ledger === undefined || !ledger.done || lapsedNone(parent, name) === undefined) return undefined;
414 return payAgainRoute(parent, request, name);
415 }
416
377417 /** Drop every hosted child App this parent has open (call when the parent stops). */
378418 export function closeHostedChildren(parent: App): void {
379419 const sweeper = sweepers.get(parent);
@@ -601,6 +641,11 @@ export async function resolveHostedChildApp(
601641 if (!CHILD_NAME.test(name)) continue;
602642 const ledger = readLedgerSync(parent, name);
603643 if (ledger === undefined || !ledger.done) continue;
644 // Billing `none`: its apps do not answer, awake or not, until paid (#11393).
645 if (lapsedNone(parent, name) !== undefined) {
646 if (isAwakeBody(parent, ledger.bodyId)) void sleepChild(parent, name).catch(() => {});
647 return { refused: `${name} is asleep because its subscription ended and was not paid again` };
648 }
604649 if (!isAwakeBody(parent, ledger.bodyId)) {
605650 const refused = startRefused(parent, ledger);
606651 if (refused !== undefined) return { refused };
src/tui/screens/entries.ts +2
@@ -18,6 +18,7 @@ import { entry as dotfiles } from "./dotfiles.ts";
1818 import { entry as devices } from "./devices.ts";
1919 import { entry as bets } from "./bets.ts";
2020 import { entry as billing } from "./billing.ts";
21 import { entry as lapsed } from "./lapsed.ts";
2122 import { entry as codes } from "./codes.ts";
2223 import { entry as boards } from "./boards.ts";
2324 import { entry as drift } from "./drift.ts";
@@ -149,6 +150,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
149150 economics,
150151 money,
151152 billing,
153 lapsed,
152154 codes,
153155 appTemplates,
154156 devices,
src/tui/screens/lapsed.ts added +89
@@ -0,0 +1,89 @@
1 /**
2 * Forges whose billing is `none` (#11393) in the terminal, a forge admin's who
3 * is a person: why each sleeps, until when its data is kept, the export line,
4 * and `p` to start the checkout to pay again, whose address is shown to hand
5 * to the payer. The same sentences as the wake page (`billing/lapse_words.ts`)
6 * and the same checkout (`billing/pay_again.ts`).
7 */
8
9 import { readBilling } from "../../actions/billing.ts";
10 import { lapseFor } from "../../billing/lapse.ts";
11 import { exportSentence, lapsedSentence } from "../../billing/lapse_words.ts";
12 import { canPayAgain, startPayAgain } from "../../billing/pay_again.ts";
13 import { publicOrigin } from "../../core/public_origin.ts";
14 import { childUrlOf } from "../../spawn/door.ts";
15 import type { Ctx, Screen } from "../app/app.ts";
16 import { binding } from "../app/keys.ts";
17 import { style } from "../term/style.ts";
18 import { truncate, wrap } from "../term/width.ts";
19 import type { Session } from "../session.ts";
20 import { emptyState } from "../ui/widgets.ts";
21 import { ListState } from "./common.ts";
22 import type { PaletteEntry } from "./entries.ts";
23
24 const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
25
26 /** The forges asleep for want of payment, with the lines that say why and how to export. */
27 export function lapsedForges(f: Session): { name: string; lines: string[] }[] {
28 const read = readBilling(f.app, f.user);
29 if (!read.ok) return [];
30 const front = publicOrigin(f.app.config.url, `http://${f.app.config.host ?? "localhost"}`);
31 const out: { name: string; lines: string[] }[] = [];
32 for (const e of read.value.forges) {
33 const lapse = lapseFor(f.app.db, e.forge);
34 if (lapse.state !== "none") continue;
35 out.push({ name: e.forge, lines: [lapsedSentence(e.forge), exportSentence(lapse, front)] });
36 }
37 return out;
38 }
39
40 export function lapsedScreen(f: Session, ctx: Ctx): Screen {
41 const state = new ListState();
42 let note = "";
43 return {
44 title: "Lapsed forges",
45 keys: [binding(["j", "k"], "move cursor"), binding(["p"], "start checkout to pay again"), binding(["esc"], "back")],
46 render(area, s) {
47 const c = ctx.theme.c;
48 const rows = lapsedForges(f);
49 state.clamp(rows.length);
50 if (rows.length === 0) {
51 emptyState(s, area, c, { title: "No forge is asleep for want of payment.", body: note });
52 return;
53 }
54 let y = area.y;
55 rows.forEach((r, i) => {
56 s.text(area.x + 1, y++, truncate(`${i === state.index ? "> " : " "}${r.name}`, area.w - 2), style(c.fg, c.bg), area.x + area.w);
57 for (const l of r.lines) for (const w of wrap(l, area.w - 6)) if (y < area.y + area.h - 1) s.text(area.x + 4, y++, w, style(c.mutedFg, c.bg), area.x + area.w);
58 });
59 if (note !== "") s.text(area.x + 1, area.y + area.h - 1, truncate(note, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w);
60 },
61 key(e) {
62 const rows = lapsedForges(f);
63 const target = rows[state.index];
64 if (e.key === "p" && target !== undefined) {
65 if (!canPayAgain(f.app, target.name)) note = "Paying is not set up on this forge yet.";
66 else {
67 note = "Making the checkout…";
68 void startPayAgain(f.app, target.name, childUrlOf(f.app, target.name).replace(/\/+$/, "")).then((made) => {
69 note = made.ok ? `Pay here: ${made.value}` : `${made.error}.`;
70 ctx.invalidate();
71 });
72 }
73 ctx.invalidate();
74 return true;
75 }
76 return state.key(e, rows.length);
77 },
78 };
79 }
80
81 /** Forges asleep for want of payment, and the checkout to pay again. */
82 export const entry: PaletteEntry = {
83 id: "lapsed",
84 title: "Lapsed forges",
85 hint: "forges asleep until paid, when their data goes, pay again",
86 after: ["billing"],
87 offered: isAdmin,
88 run: (session, ctx) => ctx.push(lapsedScreen(session, ctx)),
89 };
src/web/pages/lapsed.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * What a visitor sees at the address of a forge whose billing is `none`
3 * (#11393): one server-rendered page that says what happened in a sentence,
4 * offers the button to pay again (the sale's checkout, `billing/pay_again.ts`)
5 * and says how to take the data. The apps stay asleep until paid. A browser
6 * gets the page, anything else the sentence. The button posts to this same
7 * address; `payAgainRoute` answers it, same-origin only.
8 */
9
10 import type { App } from "../../app.ts";
11 import type { Lapse } from "../../billing/lapse.ts";
12 import { exportSentence, lapsedSentence } from "../../billing/lapse_words.ts";
13 import { canPayAgain, startPayAgain } from "../../billing/pay_again.ts";
14 import { publicOrigin } from "../../core/public_origin.ts";
15 import type { Ctx } from "../../http/context.ts";
16 import { takeDoor, tooManyStrangers } from "../../http/strangers.ts";
17 import { btn, empty } from "../ui.ts";
18 import { html } from "../html.ts";
19 import { page } from "../layout.ts";
20
21 export const PAY_AGAIN_PATH = "/pay-again";
22
23 export { exportSentence, lapsedSentence };
24
25 /** The answer for a lapsed forge's address: the page for a browser, the sentence for the rest. 402, never cached. */
26 export function lapsedResponse(app: App, request: Request, name: string, lapse: Extract<Lapse, { state: "none" }>): Response {
27 const url = new URL(request.url);
28 const front = publicOrigin(app.config.url, url.origin);
29 const exportLine = exportSentence(lapse, front);
30 if (!(request.headers.get("accept") ?? "").includes("text/html")) {
31 const text = new Response(`${lapsedSentence(name)}\n${exportLine}\n`, { status: 402, headers: { "content-type": "text/plain; charset=utf-8" } });
32 text.headers.set("cache-control", "no-store");
33 return text;
34 }
35 const ctx: Ctx = { app, request, url, params: {}, auth: { user: undefined, scope: undefined, via: "none" }, theme: undefined };
36 const pay = canPayAgain(app, name);
37 const response = page(
38 {
39 ctx,
40 bare: true,
41 title: "Asleep",
42 body: html`<div class="narrow-wide">
43 ${empty({
44 icon: "history",
45 title: "This forge is asleep",
46 note: lapsedSentence(name),
47 action: pay
48 ? html`<form method="post" action="${PAY_AGAIN_PATH}">${btn("Pay again", { variant: "primary" })}</form>`
49 : html`<p class="muted">Paying is not set up on this forge yet; ask its operator.</p>`,
50 })}
51 <p class="muted small" id="export">${exportLine}</p>
52 </div>`,
53 },
54 402,
55 );
56 response.headers.set("cache-control", "no-store");
57 return response;
58 }
59
60 /**
61 * POST /pay-again on a lapsed forge's address: make the checkout and send the
62 * payer to it. Refused unless the request comes from this very address.
63 */
64 export async function payAgainRoute(app: App, request: Request, name: string): Promise<Response> {
65 const host = request.headers.get("host") ?? "";
66 const from = request.headers.get("origin");
67 let same = false;
68 try {
69 same = from !== null && new URL(from).host === host;
70 } catch {
71 same = false;
72 }
73 if (!same) return new Response("not allowed", { status: 403, headers: { "cache-control": "no-store" } });
74 // Anyone may press it, and each press calls the payment provider: count it per address.
75 const turn = takeDoor(app, "pay", request);
76 if (!turn.allowed) return tooManyStrangers({ retryAfter: turn.retryAfter, why: "too many attempts to pay again; retry shortly" });
77 const made = await startPayAgain(app, name, new URL(request.url).origin);
78 if (!made.ok) return new Response(`${made.error}\n`, { status: 503, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
79 return new Response(null, { status: 303, headers: { location: made.value, "cache-control": "no-store" } });
80 }
outdated

No rate limit before creating a checkout. An unauthenticated POST per request calls the provider. Limit by socket address (takeDoor) and answer 429, with a test.

test/billing-lapsed-sleep.test.ts added +148
@@ -0,0 +1,148 @@
1 // A forge whose billing is `none` sleeps; a wake shows what happened, a pay-again button and how to export; paying wakes it (#11393).
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 { spawnForge } from "../src/actions/spawn.ts";
8 import { sealKey, sealWebhook } from "../src/billing/connection.ts";
9 import { useCheckout } from "../src/billing/checkout.ts";
10 import { setStripeFetch } from "../src/billing/sync.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { ok } from "../src/kernel/result.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { bootHostedChild, childAwake, closeHostedChildren, payAgainGate, resolveHostedChildApp, sleepLapsed, wakeGate } from "../src/spawn/host.ts";
15 import { hostedPlacement } from "../src/spawn/placement.ts";
16 import { notePeer } from "../src/http/peer.ts";
17 import { setStrangerLimits, strangerLimits } from "../src/http/strangers.ts";
18 import { lapsedForges } from "../src/tui/screens/lapsed.ts";
19 import type { Session } from "../src/tui/session.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23 const secret = "whsec_lapsed_sleep_secret";
24 let dir: string;
25 let app: App;
26 let servers: Servers;
27 let status = "active";
28 let n = 0;
29 let made: Record<string, unknown>[] = [];
30
31 async function deliver(next: string): Promise<void> {
32 status = next;
33 const body = JSON.stringify({ id: `evt_s${++n}`, type: "customer.subscription.updated", data: { object: { id: "sub_k", status: next } } });
34 const t = Math.floor(Date.now() / 1000);
35 const k = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
36 const mac = new Uint8Array(await crypto.subtle.sign("HMAC", k, new TextEncoder().encode(`${t}.${body}`)));
37 const res = await fetch(`http://127.0.0.1:${servers.port}/hooks/stripe`, {
38 method: "POST", headers: { "stripe-signature": `t=${t},v1=${Buffer.from(mac).toString("hex")}`, "x-forwarded-for": `10.8.0.${n}` }, body,
39 });
40 expect(res.status).toBe(200);
41 }
42
43 const visit = (init: RequestInit = {}, path = "/", headers: Record<string, string> = {}) =>
44 new Request(`https://kid--forge.test${path}`, { ...init, headers: { host: "kid--forge.test", accept: "text/html", ...headers } });
45
46 beforeAll(async () => {
47 dir = await mkdtemp(join(tmpdir(), "lapsed-sleep-"));
48 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
49 servers = await serve(app);
50 sealKey(app.db, dir, "sk_test_abcdefgh12345678");
51 sealWebhook(app.db, dir, secret);
52 setStripeFetch(app, (async () =>
53 new Response(JSON.stringify({ id: "sub_k", status, customer: "cus_k", metadata: { forge: "kid", plan: "medium" }, items: { data: [{ current_period_end: 1900000000 }] } }), { status: 200 })) as unknown as typeof fetch);
54 useCheckout(app, {
55 name: "fake",
56 create: async (req) => { made.push({ ...req }); return ok({ id: "cs_1", url: "https://pay.test/cs_1" }); },
57 state: async () => ok({ paid: false, customer: "", subscription: "" }),
58 });
59 const admin = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", isAdmin: true });
60 if (!admin.ok) throw new Error("user");
61 const out = await spawnForge(
62 app, admin.value, { name: "kid", org: "kid", apps: [], carryBriefs: false, carrySubscriptions: false },
63 { catalog: [], briefs: [], subscriptions: [] },
64 { placement: hostedPlacement({ host: "forge.test" }), request: new Request("https://forge.test/spawn", { method: "POST", headers: { origin: "https://forge.test" } }), url: new URL("https://forge.test/spawn") },
65 );
66 if (!out.ok) throw new Error(JSON.stringify(out.error));
67 await bootHostedChild(app, "kid");
68 });
69 afterAll(async () => {
70 useCheckout(app, undefined);
71 setStripeFetch(app, undefined);
72 closeHostedChildren(app);
73 await servers.stop();
74 await rm(dir, { recursive: true, force: true });
75 });
76
77 describe("a forge whose billing is none", () => {
78 test("none → asleep and unwakeable → paid → awake", async () => {
79 await deliver("active");
80 expect(childAwake(app, "kid")).toBe(true);
81 expect(await sleepLapsed(app)).toEqual([]);
82
83 await deliver("canceled");
84 expect(await sleepLapsed(app)).toEqual(["kid"]);
85 expect(childAwake(app, "kid")).toBe(false);
86
87 // a wake shows the page: what happened, the button, how to export
88 const woke = wakeGate(app, visit());
89 expect(woke?.status).toBe(402);
90 const body = await woke!.text();
91 expect(body).toContain("kid is asleep because its subscription ended");
92 expect(body).toContain("Pay again");
93 expect(body).toContain('action="/pay-again"');
94 expect(body).toContain("ask this forge&#39;s operator");
95 expect(woke!.headers.get("cache-control")).toBe("no-store");
96 // a client that is not a browser gets the sentences
97 const plain = wakeGate(app, visit({}, "/", { accept: "application/json" }));
98 expect(plain?.status).toBe(402);
99 expect(await plain!.text()).toContain("kid is asleep");
100 // its apps do not answer, and nothing wakes
101 const hit = await resolveHostedChildApp(app, new Request("https://web--kid--forge.test/", { headers: { host: "web--kid--forge.test" } }));
102 expect(hit).toMatchObject({ refused: expect.stringContaining("asleep") });
103 expect(childAwake(app, "kid")).toBe(false);
104
105 // the button: same-origin only, then the checkout
106 expect((await payAgainGate(app, visit({ method: "POST" }, "/pay-again")))?.status).toBe(403);
107 expect((await payAgainGate(app, visit({ method: "POST" }, "/pay-again", { origin: "https://evil.test" })))?.status).toBe(403);
108 expect(await payAgainGate(app, visit({}, "/pay-again"))).toBeUndefined();
109 const paying = await payAgainGate(app, visit({ method: "POST" }, "/pay-again", { origin: "https://kid--forge.test" }));
110 expect(paying?.status).toBe(303);
111 expect(paying?.headers.get("location")).toBe("https://pay.test/cs_1");
112 expect(made[0]).toMatchObject({ forge: "kid", plan: "medium" });
113
114 // paid: the provider's webhook, and it wakes as it was
115 await deliver("active");
116 expect(await payAgainGate(app, visit({ method: "POST" }, "/pay-again", { origin: "https://kid--forge.test" }))).toBeUndefined();
117 const after = wakeGate(app, visit());
118 expect(after?.status).not.toBe(402);
119 expect(await sleepLapsed(app)).toEqual([]);
120 });
121
122 test("pressing pay again is counted per address and refused with 429 past the limit", async () => {
123 await deliver("canceled");
124 setStrangerLimits(app, { perAddress: { ...strangerLimits(app).perAddress, pay: 2 } });
125 const press = (ip: string) => {
126 const request = visit({ method: "POST" }, "/pay-again", { origin: "https://kid--forge.test" });
127 notePeer(request, ip);
128 return payAgainGate(app, request);
129 };
130 const before = made.length;
131 expect((await press("203.0.113.7"))?.status).toBe(303);
132 expect((await press("203.0.113.7"))?.status).toBe(303);
133 const third = await press("203.0.113.7");
134 expect(third?.status).toBe(429);
135 expect(third?.headers.get("retry-after")).toBeTruthy();
136 expect(made.length).toBe(before + 2);
137 // another address has its own minute
138 expect((await press("198.51.100.9"))?.status).toBe(303);
139 });
140
141 test("the terminal lists the lapsed forge with the sentence and the keep-until line", async () => {
142 const admin = { id: "x", username: "alice", isAdmin: true, kind: "person" };
143 const rows = lapsedForges({ app, user: admin } as unknown as Session);
144 expect(rows.map((r) => r.name)).toEqual(["kid"]);
145 expect(rows[0]!.lines[0]).toContain("kid is asleep");
146 expect(rows[0]!.lines[1]).toContain("Your data is kept until");
147 });
148 });
test/palette-roster.test.ts +1
@@ -173,6 +173,7 @@ test("a site-admin person is offered the whole roster", () => {
173173 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
174174 ["security-controls", "Security controls", "each safeguard, and when it was last tested"],
175175 ["billing", "Billing", "each forge's plan, and grant one by hand"],
176 ["lapsed", "Lapsed forges", "forges asleep until paid, when their data goes, pay again"],
176177 ["codes", "Gift codes", "make a code that gives a plan for free, and revoke one"],
177178 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],
178179 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs