Public: anyone can read this; the crew can change it.
small: a preview shows a forge in grace, so the forge's own looks picture the Plan line and the /forges row, and docs/features/plan-row.md shows them #11551
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11549-small-a-preview-shows-a-forge-in-grace-s → main 1 commit fork
4 files
changed +106
−3
docs/features/plan-row.md +1
| @@ -7,3 +7,4 @@ A forge's admins see one `status-rows` `dl` row (`Plan`) at the top of `/setting | ||
| 7 | 7 | - **Host:** Settings → Billing → *Billing portal* sets `billing.portal_url` (https only, empty clears). Changing it tells every forge sent from here. |
| 8 | 8 | - **How a forge learns its plan:** the host writes a notice (`src/billing/notice.ts`) into that one forge's own `plan.json` (never `parent.json`) when the forge is made, a gift is redeemed, a plan is granted, or Stripe changes the subscription. The forge reads only its own file and calls nothing on the host, so it cannot read another forge's plan; the notice has no forge name, customer or subscription id. |
| 9 | 9 | - **Terminal:** the palette entry *Plan* (`src/tui/screens/planrow.ts`, any admin) shows the same plan line (`planLine`) and, for an admin who is a person, sets the billing portal address with Enter through `changePortalUrl`, as the page does. |
| 10 | - **Preview (#11549):** a preview is seeded in grace (`seedGrace` in `src/spawn/preview-seed.ts`, only when `FORGE_APP_ENV=preview`): its own `plan.json` says payment is late and one client forge is in grace, so the forge's looks of `/settings` (the Plan row) and `/forges` (`grace until <date>`), light and dark, desk and phone, are on every pull's looks page (`PREVIEW_OWNER_LOOKS`). |
src/spawn/preview-looks.ts +2
| @@ -193,6 +193,8 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [ | ||
| 193 | 193 | PREVIEW_MACHINES_PAGE, |
| 194 | 194 | PREVIEW_DISK_OPEN_LOOK, |
| 195 | 195 | ...PREVIEW_SETTINGS_PAGES, |
| 196 | "/settings", | |
| 197 | "/forges", | |
| 196 | 198 | ].flatMap((page) => |
| 197 | 199 | (["desktop", "phone"] as const).map((kind): OwnerLook => |
| 198 | 200 | page === PREVIEW_NEWCOMER_PAGE ? { page, kind, as: "newcomer" } : { page, kind }, |
src/spawn/preview-seed.ts +41 −3
| @@ -14,7 +14,9 @@ | ||
| 14 | 14 | * to show or log. Once: the admin remembers that it was seeded. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { existsSync } from "node:fs"; | |
| 17 | import { existsSync, mkdirSync, writeFileSync } from "node:fs"; | |
| 18 | import { keepNotice, noticeOf } from "../billing/notice.ts"; | |
| 19 | import { putEntitlement } from "../billing/store.ts"; | |
| 18 | 20 | import { join } from "node:path"; |
| 19 | 21 | import type { App } from "../app.ts"; |
| 20 | 22 | import { createOrg } from "../core/orgs.ts"; |
| @@ -327,7 +329,9 @@ export async function seedPreview( | ||
| 327 | 329 | const files = await seedConfigFiles(app, admin); |
| 328 | 330 | if (!files.ok) return files; |
| 329 | 331 | const newcomer = await seedNewcomer(app); |
| 330 | return newcomer.ok ? ok({ seeded: false }) : newcomer; | |
| 332 | if (!newcomer.ok) return newcomer; | |
| 333 | seedGrace(app); | |
| 334 | return ok({ seeded: false }); | |
| 331 | 335 | } |
| 332 | 336 | const world = await seedWorld(app, admin); |
| 333 | 337 | if (!world.ok) return world; |
| @@ -341,7 +345,41 @@ export async function seedPreview( | ||
| 341 | 345 | const files = await seedConfigFiles(app, admin); |
| 342 | 346 | if (!files.ok) return files; |
| 343 | 347 | const newcomer = await seedNewcomer(app); |
| 344 | return newcomer.ok ? ok({ seeded: true }) : newcomer; | |
| 348 | if (!newcomer.ok) return newcomer; | |
| 349 | seedGrace(app); | |
| 350 | return ok({ seeded: true }); | |
| 351 | } | |
| 352 | ||
| 353 | /** The client forge the preview's operator view shows in grace. */ | |
| 354 | export const PREVIEW_GRACE_CLIENT = "preview-grace-client"; | |
| 355 | ||
| 356 | /** | |
| 357 | * A forge in grace, so the forge's looks picture the Plan row of /settings | |
| 358 | * and the grace words of the operator's /forges row (#11549). Only a preview | |
| 359 | * reaches it. The forge's own `plan.json` (never `parent.json`) tells it | |
| 360 | * payment is late, and one client forge holds a late entitlement. Written | |
| 361 | * only where absent, so it is once and a reboot changes nothing. | |
| 362 | */ | |
| 363 | function seedGrace(app: App): void { | |
| 364 | const dir = app.config.dataDir; | |
| 365 | if (noticeOf(dir) === undefined) { | |
| 366 | keepNotice(dir, { | |
| 367 | name: "Medium", | |
| 368 | size: "medium", | |
| 369 | priceUsd: 49, | |
| 370 | creditUsd: 10, | |
| 371 | status: "grace", | |
| 372 | periodEnd: Math.floor(Date.now() / 1000) + 5 * 86_400, | |
| 373 | }); | |
| 374 | } | |
| 375 | const record = join(dir, "spawn", `${PREVIEW_GRACE_CLIENT}.json`); | |
| 376 | if (existsSync(record)) return; | |
| 377 | mkdirSync(join(dir, "spawn"), { recursive: true }); | |
| 378 | writeFileSync( | |
| 379 | record, | |
| 380 | JSON.stringify({ name: PREVIEW_GRACE_CLIENT, bodyId: "body-preview-grace", done: true, size: "small", org: PREVIEW_GRACE_CLIENT, apps: [] }), | |
| 381 | ); | |
| 382 | putEntitlement(app.db, { forge: PREVIEW_GRACE_CLIENT, plan: "medium", status: "grace", periodEnd: Math.floor(Date.now() / 1000), provider: "stripe", customerId: "cus_preview", subscriptionId: "sub_preview", source: "stripe" }, "small", ""); | |
| 345 | 383 | } |
| 346 | 384 | |
| 347 | 385 | /** |
test/preview-grace.test.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { existsSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { PREVIEW_OWNER_LOOKS } from "../src/spawn/preview-looks.ts"; | |
| 10 | import { previewAdmin, seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 11 | ||
| 12 | let dir = ""; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | beforeAll(async () => { | |
| 16 | dir = await mkdtemp(join(tmpdir(), "forge-preview-grace-")); | |
| 17 | app = createApp({ dataDir: dir, port: 0 }); | |
| 18 | const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 19 | if (!seeded.ok) throw new Error(seeded.error); | |
| 20 | servers = await serve(app); | |
| 21 | }, 60_000); | |
| 22 | afterAll(async () => { | |
| 23 | servers.stop(); | |
| 24 | app.close(); | |
| 25 | await rm(dir, { recursive: true, force: true }); | |
| 26 | }); | |
| 27 | ||
| 28 | const get = async (path: string): Promise<string> => { | |
| 29 | const cookie = `forge_session=${createSession(app.db, previewAdmin(app)!.id).token}`; | |
| 30 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 31 | expect(res.status).toBe(200); | |
| 32 | return res.text(); | |
| 33 | }; | |
| 34 | ||
| 35 | test("a seeded preview shows the Plan row in grace and the grace row on /forges", async () => { | |
| 36 | expect(await get("/settings")).toMatch(/Payment due — paused on \d+ \w+/); | |
| 37 | expect(await get("/forges")).toMatch(/grace until \d{4}-\d{2}-\d{2}/); | |
| 38 | }); | |
| 39 | ||
| 40 | test("home carries no notice, and a second seed changes nothing", async () => { | |
| 41 | expect(await get("/")).not.toContain("Payment due"); | |
| 42 | const again = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 43 | expect(again.ok && again.value.seeded).toBe(false); | |
| 44 | }); | |
| 45 | ||
| 46 | test("the looks take /settings and /forges", () => { | |
| 47 | for (const p of ["/settings", "/forges"]) expect(PREVIEW_OWNER_LOOKS.filter((l) => l.page === p).length).toBe(2); | |
| 48 | }); | |
| 49 | ||
| 50 | test("outside a preview nothing is seeded", async () => { | |
| 51 | const other = await mkdtemp(join(tmpdir(), "forge-no-grace-")); | |
| 52 | const a = createApp({ dataDir: other, port: 0 }); | |
| 53 | try { | |
| 54 | const r = await seedPreview(a, { FORGE_APP_ENV: "production" }); | |
| 55 | expect(r.ok).toBe(false); | |
| 56 | expect(existsSync(join(other, "plan.json"))).toBe(false); | |
| 57 | expect(existsSync(join(other, "spawn"))).toBe(false); | |
| 58 | } finally { | |
| 59 | a.close(); | |
| 60 | await rm(other, { recursive: true, force: true }); | |
| 61 | } | |
| 62 | }); |
new above
offline