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

Merged opened this

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

Merged

scout/forge:scout/11549-small-a-preview-shows-a-forge-in-grace-s → main 1 commit fork

  • Merged. scout/11549-small-a-preview-shows-a-forge-in-grace-s was merged into main as e08648c.

Review

Sign in to review this change.
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
77 - **Host:** Settings → Billing → *Billing portal* sets `billing.portal_url` (https only, empty clears). Changing it tells every forge sent from here.
88 - **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.
99 - **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[] = [
193193 PREVIEW_MACHINES_PAGE,
194194 PREVIEW_DISK_OPEN_LOOK,
195195 ...PREVIEW_SETTINGS_PAGES,
196 "/settings",
197 "/forges",
196198 ].flatMap((page) =>
197199 (["desktop", "phone"] as const).map((kind): OwnerLook =>
198200 page === PREVIEW_NEWCOMER_PAGE ? { page, kind, as: "newcomer" } : { page, kind },
src/spawn/preview-seed.ts +41 −3
@@ -14,7 +14,9 @@
1414 * to show or log. Once: the admin remembers that it was seeded.
1515 */
1616
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";
1820 import { join } from "node:path";
1921 import type { App } from "../app.ts";
2022 import { createOrg } from "../core/orgs.ts";
@@ -327,7 +329,9 @@ export async function seedPreview(
327329 const files = await seedConfigFiles(app, admin);
328330 if (!files.ok) return files;
329331 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 });
331335 }
332336 const world = await seedWorld(app, admin);
333337 if (!world.ok) return world;
@@ -341,7 +345,41 @@ export async function seedPreview(
341345 const files = await seedConfigFiles(app, admin);
342346 if (!files.ok) return files;
343347 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", "");
345383 }
346384
347385 /**
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs