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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11393-a-forge-whose-billing-is-none-sleeps-and → main 5 commits fork 1 thread · 1 unresolved
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.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 { | ||
| 606 | 606 | refusedResponse, |
| 607 | 607 | socketToCell, |
| 608 | 608 | wakeGate, |
| 609 | payAgainGate, | |
| 609 | 610 | watchHostedChildClose, |
| 610 | 611 | } from "../spawn/host.ts"; |
| 611 | 612 | import { getVerifiedDomain } from "../core/domains.ts"; |
| @@ -2652,6 +2653,8 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2652 | 2653 | // child's own App before the parent's pages. Ingress routes the label |
| 2653 | 2654 | // here; forge terminates Host routing in-process. |
| 2654 | 2655 | { |
| 2656 | const paying = await payAgainGate(app, request); | |
| 2657 | if (paying !== undefined) return securityFloor(paying); | |
| 2655 | 2658 | const gated = wakeGate(app, request); |
| 2656 | 2659 | if (gated !== undefined) return securityFloor(gated); |
| 2657 | 2660 | const child = await resolveHostedChild(app, request); |
src/http/strangers.ts +3 −3
| @@ -23,7 +23,7 @@ import { transferGate } from "../git/env.ts"; | ||
| 23 | 23 | import { bucketOf, clientBucket } from "./peer.ts"; |
| 24 | 24 | |
| 25 | 25 | /** 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"; | |
| 27 | 27 | |
| 28 | 28 | export interface StrangerLimits { |
| 29 | 29 | /** Requests per minute through each door, per client address. */ |
| @@ -59,8 +59,8 @@ export interface StrangerLimits { | ||
| 59 | 59 | } |
| 60 | 60 | |
| 61 | 61 | 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 }, | |
| 64 | 64 | transfersPerAddress: 4, |
| 65 | 65 | transferShare: 0.5, |
| 66 | 66 | transferTimeoutMs: 7_200_000, |
src/spawn/host.ts +46 −1
| @@ -31,6 +31,8 @@ import { floorRefusal } from "./disk_floor.ts"; | ||
| 31 | 31 | import { limitValue } from "../settings/limits.ts"; |
| 32 | 32 | import { awakeRefusal, idleAfterMs, WakeRate } from "./sleep.ts"; |
| 33 | 33 | 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"; | |
| 34 | 36 | import { takeDoor } from "../http/strangers.ts"; |
| 35 | 37 | import { vouched } from "./vouch.ts"; |
| 36 | 38 | import { during } from "../perf/jobs.ts"; |
| @@ -256,6 +258,25 @@ export async function sleepChild(parent: App, name: string): Promise<boolean> { | ||
| 256 | 258 | return true; |
| 257 | 259 | } |
| 258 | 260 | |
| 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 | ||
| 259 | 280 | /** Whether a child is awake: open in this process or running in a cell. */ |
| 260 | 281 | export function childAwake(parent: App, name: string): boolean { |
| 261 | 282 | const ledger = readLedgerSync(parent, name); |
| @@ -283,7 +304,10 @@ async function closeOne(parent: App, cache: Map<string, App>, bodyId: string, ch | ||
| 283 | 304 | function keepSweeping(parent: App): void { |
| 284 | 305 | if (sweepers.has(parent)) return; |
| 285 | 306 | 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); | |
| 287 | 311 | timer.unref?.(); |
| 288 | 312 | sweepers.set(parent, timer); |
| 289 | 313 | } |
| @@ -340,6 +364,12 @@ export function wakeGate(parent: App, request: Request): Response | undefined { | ||
| 340 | 364 | if (isAwakeBody(parent, ledger.bodyId)) void sleepChild(parent, name).catch(() => {}); |
| 341 | 365 | return refusedResponse(floor); |
| 342 | 366 | } |
| 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 | } | |
| 343 | 373 | const known = carriesCredentials(parent, ledger.bodyId, request); |
| 344 | 374 | if (isAwakeBody(parent, ledger.bodyId)) { |
| 345 | 375 | if (known) { |
| @@ -374,6 +404,16 @@ export function wakeGate(parent: App, request: Request): Response | undefined { | ||
| 374 | 404 | return undefined; |
| 375 | 405 | } |
| 376 | 406 | |
| 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 | ||
| 377 | 417 | /** Drop every hosted child App this parent has open (call when the parent stops). */ |
| 378 | 418 | export function closeHostedChildren(parent: App): void { |
| 379 | 419 | const sweeper = sweepers.get(parent); |
| @@ -601,6 +641,11 @@ export async function resolveHostedChildApp( | ||
| 601 | 641 | if (!CHILD_NAME.test(name)) continue; |
| 602 | 642 | const ledger = readLedgerSync(parent, name); |
| 603 | 643 | 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 | } | |
| 604 | 649 | if (!isAwakeBody(parent, ledger.bodyId)) { |
| 605 | 650 | const refused = startRefused(parent, ledger); |
| 606 | 651 | if (refused !== undefined) return { refused }; |
src/tui/screens/entries.ts +2
| @@ -18,6 +18,7 @@ import { entry as dotfiles } from "./dotfiles.ts"; | ||
| 18 | 18 | import { entry as devices } from "./devices.ts"; |
| 19 | 19 | import { entry as bets } from "./bets.ts"; |
| 20 | 20 | import { entry as billing } from "./billing.ts"; |
| 21 | import { entry as lapsed } from "./lapsed.ts"; | |
| 21 | 22 | import { entry as codes } from "./codes.ts"; |
| 22 | 23 | import { entry as boards } from "./boards.ts"; |
| 23 | 24 | import { entry as drift } from "./drift.ts"; |
| @@ -149,6 +150,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 149 | 150 | economics, |
| 150 | 151 | money, |
| 151 | 152 | billing, |
| 153 | lapsed, | |
| 152 | 154 | codes, |
| 153 | 155 | appTemplates, |
| 154 | 156 | 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 | } | |
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'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", () => { | ||
| 173 | 173 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 174 | 174 | ["security-controls", "Security controls", "each safeguard, and when it was last tested"], |
| 175 | 175 | ["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"], | |
| 176 | 177 | ["codes", "Gift codes", "make a code that gives a plan for free, and revoke one"], |
| 177 | 178 | ["archived", "Archived repositories", "put away, not gone: open one or restore it"], |
| 178 | 179 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
new above
offline
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.