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

An admin makes gift codes in the forge: a code gives a plan for free, with or without a payment provider #11166

Merged opened this in answer to

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

Merged

scout/forge:scout/11157-an-admin-makes-gift-codes-in-the-forge-a → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/11157-an-admin-makes-gift-codes-in-the-forge-a was merged into main as a985b29.

Review

Sign in to review this change.
28 files changed +1164 −21
docs/MCP.md +1 −1
@@ -112,7 +112,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
112112 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
113113 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
114114 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 |
115 | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 257 |
115 | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 336 |
116116 | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 |
117117 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 397 |
118118 | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 |
docs/features/billing.md +8
@@ -6,3 +6,11 @@ Billing records the plan each forge is on (`src/billing/`): a store of entitleme
66 - **Grant by hand (admin, a person):** the Billing page, the terminal screen, `PUT /api/v1/forges/{name}/plan` with `{ "plan": "large" }` (`GET` reads it), or the MCP `forges` tool with `{ forge, plan }` (planned first; `dryRun: false` grants).
77 - **Audit:** every grant is a row in `entitlement_change` and a `billing.plan` event.
88 - **Terminal:** the palette's *Billing* (admins), `src/tui/screens/billing.ts`: `j`/`k` pick a forge, `s`, `m` or `l` grants that plan after a yes/no.
9
10 ## Gift codes
11 A code gives a plan for free, with or without a payment provider (`src/billing/codes.ts`, `src/actions/codes.ts`). No provider is asked: a redeemed code writes the entitlement itself, source `code:<id>`.
12
13 - **Make (admin, a person):** Settings → Billing → Codes (below Plans) → *Make a code*, the terminal's *Gift codes* screen (`n` makes, `p` picks the plan, `x` revokes after a yes; live, so a code made or redeemed anywhere shows at once) (plan, uses, note, expiry), `POST /api/v1/codes` (`dryRun` true unless `false`), or the MCP `forges` tool with `code: { plan, uses?, note?, expires? }`. The plain code (`forge-7kq4-m2xp`) and its link `…/?code=<code>` are shown once; only a hash is kept.
14 - **List / revoke:** the same page (one row per code: plan and note, used of limit, who redeemed, and one *Stop this code working* button), `GET /api/v1/codes`, `DELETE /api/v1/codes/{id}`, MCP `codes: true` / `revoke: <id>`. Revoking stops new uses; forges already made keep their plan.
15 - **Redeem:** the link moves the code into an HttpOnly cookie and opens `/forges/new` (no code in the address, the log or the history) with the code filled in ("A gift: a medium forge, free."; the admin is not named). Without a code the field sits behind a "Have a code?" disclosure; `POST /api/v1/forges` and the MCP `spawn` take `code`. A wrong, used-up, expired or revoked code is said on the same form. A client may try 10 codes in 10 minutes.
16 - **Audit:** `billing.code.make`, `billing.code.redeem`, `billing.code.revoke` events.
src/actions/codes.ts added +116
@@ -0,0 +1,116 @@
1 /**
2 * Gift codes as they are reached (#11157). An admin who is a person makes,
3 * lists and revokes them (never an agent: a crew that could mint a plan would
4 * decide its own limits; anyone else is told `not_found`). A visitor, signed
5 * in or not, redeems one when making a forge (`spawn/door.ts`): the forge is
6 * made on the code's plan with an entitlement whose source is `code:<id>`,
7 * and no payment provider is asked. Every make, redeem and revoke is a
8 * `billing.code.*` event, so it shows in the audit trail.
9 */
10
11 import type { App } from "../app.ts";
12 import {
13 CODE_WORDS, MAX_NOTE, MAX_USES, insertCode, listCodes, lookupCode, noteRedeemed,
14 releaseCode, revokeCode, takeTry, type Code, type CodeRefusal, type Listed,
15 } from "../billing/codes.ts";
16 import { putEntitlement } from "../billing/store.ts";
17 import type { User } from "../core/users.ts";
18 import { clientBucket } from "../http/peer.ts";
19 import { err, ok, type Result } from "../kernel/result.ts";
20 import { FREE, getPlan, getPlans } from "../settings/plans.ts";
21
22 export type CodeError = "not_found" | "bad_plan" | "bad_uses" | "bad_note" | "bad_expiry" | "no_such_code";
23
24 export const CODE_ERRORS: Record<Exclude<CodeError, "not_found">, string> = {
25 bad_plan: "A code gives a plan other than free: name one of the paid plans",
26 bad_uses: `Uses is a whole number from 1 to ${MAX_USES}`,
27 bad_note: `A note is at most ${MAX_NOTE} characters, on one line`,
28 bad_expiry: "An expiry is a date or time in the future, within five years",
29 no_such_code: "No code by that id",
30 };
31
32 const admin = (a: User | undefined): Result<User, "not_found"> => (a?.isAdmin === true && a.kind !== "agent" ? ok(a) : err("not_found"));
33
34 export interface CodePlan {
35 readonly plan: string;
36 readonly note: string;
37 readonly uses: number;
38 readonly expiresAt?: string;
39 }
40
41 /** The validation of a code without making it. */
42 export function planCode(app: App, actor: User | undefined, input: Record<string, unknown>, now = new Date()): Result<CodePlan, CodeError> {
43 const who = admin(actor);
44 if (!who.ok) return who;
45 const plan = typeof input["plan"] === "string" ? getPlan(app.db, input["plan"]) : undefined;
46 if (plan === undefined || plan.key === FREE) return err("bad_plan");
47 const rawUses = input["uses"];
48 const usesIn = rawUses === undefined || rawUses === null || rawUses === "" ? 1 : typeof rawUses === "string" && /^\d{1,4}$/.test(rawUses.trim()) ? Number(rawUses) : rawUses;
49 if (typeof usesIn !== "number" || !Number.isInteger(usesIn) || usesIn < 1 || usesIn > MAX_USES) return err("bad_uses");
50 const rawNote = input["note"];
51 const note = rawNote === undefined || rawNote === null ? "" : typeof rawNote === "string" ? rawNote.trim() : undefined;
52 // eslint-disable-next-line no-control-regex
53 if (note === undefined || note.length > MAX_NOTE || /[\u0000-\u001f\u007f]/.test(note)) return err("bad_note");
54 const expiry = expiryOf(input["expires"], now);
55 if (expiry === null) return err("bad_expiry");
56 return ok({ plan: plan.key, note, uses: usesIn, ...(expiry === undefined ? {} : { expiresAt: expiry }) });
57 }
58
59 /** A date (end of that day, UTC) or an ISO time; undefined when none is given, null when it is not acceptable. */
60 function expiryOf(v: unknown, now: Date): string | undefined | null {
61 if (v === undefined || v === null || v === "") return undefined;
62 if (typeof v !== "string" || v.length > 40) return null;
63 const s = v.trim();
64 const at = /^\d{4}-\d{2}-\d{2}$/.test(s) ? Date.parse(`${s}T23:59:59.999Z`) : /^\d{4}-\d{2}-\d{2}T[\d:.]+(Z|[+-]\d{2}:\d{2})$/.test(s) ? Date.parse(s) : NaN;
65 if (!Number.isFinite(at) || at <= now.getTime() || at > now.getTime() + 5 * 365 * 86_400_000) return null;
66 return new Date(at).toISOString();
67 }
68
69 export function readCodes(app: App, actor: User | undefined): Result<{ codes: Listed[]; plans: string[] }, "not_found"> {
70 const who = admin(actor);
71 if (!who.ok) return who;
72 return ok({ codes: listCodes(app.db), plans: getPlans(app.db).filter((p) => p.key !== FREE).map((p) => p.key) });
73 }
74
75 /** The link to send: the home page with the code already filled in. */
76 export const linkFor = (origin: string, code: string): string => `${origin}/?code=${encodeURIComponent(code)}`;
77
78 /** An admin makes a code. The plain code is in this answer and nowhere else. */
79 export function makeCode(app: App, actor: User | undefined, input: Record<string, unknown>, origin: string): Result<{ code: string; link: string; made: Code }, CodeError> {
80 const planned = planCode(app, actor, input);
81 if (!planned.ok) return planned;
82 const who = actor as User;
83 const { code, row } = insertCode(app.db, { ...planned.value, madeBy: who.id });
84 app.bus.append({ kind: "billing.code.make", actorId: who.id, payload: { id: row.id, plan: row.plan, uses: row.uses, note: row.note, ...(row.expiresAt === undefined ? {} : { expires: row.expiresAt }) } });
85 return ok({ code, link: linkFor(origin, code), made: row });
86 }
87
88 /** An admin revokes a code: no new uses; forges already made keep their plan. */
89 export function revoke(app: App, actor: User | undefined, id: unknown): Result<{ id: string }, CodeError> {
90 const who = admin(actor);
91 if (!who.ok) return who;
92 if (typeof id !== "string" || !revokeCode(app.db, id)) return err("no_such_code");
93 app.bus.append({ kind: "billing.code.revoke", actorId: (actor as User).id, payload: { id } });
94 return ok({ id });
95 }
96
97 export type RedeemRefusal = CodeRefusal | "slow_down";
98 export const REDEEM_WORDS: Record<RedeemRefusal, string> = { ...CODE_WORDS, slow_down: "Too many codes tried from here; try again in a few minutes" };
99
100 /** What a visitor sees of a code before using it: that it is a gift, and which plan. The admin is not named. Counts one try for the client. */
101 export function giftOf(app: App, request: Request, input: unknown): Result<{ id: string; plan: string }, RedeemRefusal> {
102 if (!takeTry(app.db, clientBucket(request))) return err("slow_down");
103 const seen = lookupCode(app.db, input);
104 return seen.ok ? ok({ id: seen.value.id, plan: seen.value.plan }) : seen;
105 }
106
107 /** The forge was not made: the use goes back. */
108 export const unclaimGift = (app: App, code: Code): void => releaseCode(app.db, code.id);
109
110 /** The forge was made: record who redeemed it, give it the plan with source `code:<id>`, and audit it. */
111 export function settleGift(app: App, code: Code, forge: string): void {
112 const size = getPlan(app.db, code.plan)?.size ?? "small";
113 noteRedeemed(app.db, code.id, forge);
114 putEntitlement(app.db, { forge, plan: code.plan, status: "active", source: `code:${code.id}`, provider: "none" }, size, "");
115 app.bus.append({ kind: "billing.code.redeem", payload: { id: code.id, forge, plan: code.plan } });
116 }
src/actions/spawn.ts +5 −1
@@ -96,6 +96,8 @@ export interface SpawnDeps {
9696 /** The request that asked, for the same-origin check on this mutation. */
9797 readonly request: Request;
9898 readonly url: URL;
99 /** The plan was given by a gift code the caller has already claimed (`actions/codes.ts`): no admin, no payment. */
100 readonly gift?: boolean;
99101 /**
100102 * The child's own model key, when the spawner gives it one: an API key, or
101103 * a person's own setup-token under a budget (their choice), sealed in the child with the
@@ -283,7 +285,9 @@ async function make(
283285 const chosen = plan.plan === undefined ? undefined : getPlan(app.db, plan.plan);
284286 if (plan.plan !== undefined && chosen === undefined)
285287 return err({ kind: "invalid", field: "plan", reason: `${plan.plan} is not a plan this forge offers` });
286 if (chosen !== undefined && chosen.key !== FREE) {
288 if (chosen !== undefined && chosen.key !== FREE && deps.gift === true) {
289 // A redeemed gift code (`actions/codes.ts`) is what lends this plan: it asks no admin and no payment.
290 } else if (chosen !== undefined && chosen.key !== FREE) {
287291 if (owner === undefined) return err({ kind: "refused", reason: "plan", message: `the ${chosen.name} plan is for a signed-in person; sign in first` });
288292 if (!owner.isAdmin) return err({ kind: "refused", reason: "plan", message: PAID_PLAN_ADMIN_ONLY(chosen.name) });
289293 const many = planRefusal(app.config.dataDir, owner, chosen, FREE);
src/billing/codes.ts added +169
@@ -0,0 +1,169 @@
1 /**
2 * Gift codes (#11157): the store. A code is short, typed easily and
3 * unguessable (`forge-7kq4-m2xp`: 8 characters of a 31-letter alphabet, about
4 * 40 bits, with redemption rate-limited per client). Only its SHA-256 is
5 * kept; the plain code is shown once to the admin who made it. A use is
6 * claimed in one statement, so two visitors cannot share the last use. Pure
7 * data; authorization is in `actions/codes.ts`.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { err, ok, type Result } from "../kernel/result.ts";
12
13 const ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
14 const SHAPE = /^forge-[a-z0-9]{4}-[a-z0-9]{4}$/;
15 export const MAX_USES = 1000;
16 export const MAX_NOTE = 80;
17
18 export type CodeRefusal = "unknown" | "used_up" | "expired" | "revoked";
19
20 export const CODE_WORDS: Record<CodeRefusal, string> = {
21 unknown: "That code is not one this forge knows",
22 used_up: "That code has been used as many times as it allows",
23 expired: "That code has expired",
24 revoked: "That code was cancelled",
25 };
26
27 export interface Code {
28 readonly id: string;
29 readonly plan: string;
30 readonly note: string;
31 readonly uses: number;
32 readonly used: number;
33 readonly expiresAt?: string;
34 readonly revokedAt?: string;
35 readonly madeBy: string;
36 readonly madeAt: string;
37 }
38
39 export interface Redeemed {
40 readonly forge: string;
41 readonly at: string;
42 }
43
44 interface Row {
45 id: string; plan: string; note: string; uses: number; used: number; expires_at: string | null;
46 revoked_at: string | null; made_by: string; made_at: string;
47 }
48
49 const fromRow = (r: Row): Code => ({
50 id: r.id, plan: r.plan, note: r.note, uses: r.uses, used: r.used,
51 ...(r.expires_at === null ? {} : { expiresAt: r.expires_at }),
52 ...(r.revoked_at === null ? {} : { revokedAt: r.revoked_at }),
53 madeBy: r.made_by, madeAt: r.made_at,
54 });
55
56 /** What a person typed, as the code it means: trimmed, lowercased, and of the code's shape, else undefined. */
57 export function normalizeCode(input: unknown): string | undefined {
58 if (typeof input !== "string" || input.length > 64) return undefined;
59 const code = input.trim().toLowerCase();
60 return SHAPE.test(code) ? code : undefined;
61 }
62
63 export const hashCode = (code: string): string => new Bun.CryptoHasher("sha256").update(`gift-code:${code}`).digest("hex");
64
65 function randomGroup(): string {
66 const bytes = crypto.getRandomValues(new Uint8Array(4));
67 return [...bytes].map((b) => ALPHABET[b % ALPHABET.length]).join("");
68 }
69
70 export interface NewCode {
71 plan: string;
72 note: string;
73 uses: number;
74 expiresAt?: string;
75 madeBy: string;
76 }
77
78 /** Make a code: its row (hashed) and the plain code, which exists nowhere else. */
79 export function insertCode(db: Database, n: NewCode, now = new Date()): { code: string; row: Code } {
80 const code = `forge-${randomGroup()}-${randomGroup()}`;
81 const id = crypto.randomUUID().slice(0, 8);
82 const at = now.toISOString();
83 db.query(
84 "INSERT INTO gift_code (id, hash, plan, note, uses, expires_at, made_by, made_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
85 ).run(id, hashCode(code), n.plan, n.note, n.uses, n.expiresAt ?? null, n.madeBy, at);
86 return { code, row: getCode(db, id)! };
87 }
88
89 export function getCode(db: Database, id: string): Code | undefined {
90 const r = db.query<Row, [string]>("SELECT * FROM gift_code WHERE id = ?1").get(id);
91 return r ? fromRow(r) : undefined;
92 }
93
94 const refusalOf = (c: Code, now: Date): CodeRefusal | undefined =>
95 c.revokedAt !== undefined ? "revoked"
96 : c.expiresAt !== undefined && Date.parse(c.expiresAt) <= now.getTime() ? "expired"
97 : c.used >= c.uses ? "used_up"
98 : undefined;
99
100 /** The code a person typed, if it can be used now; nothing is spent. */
101 export function lookupCode(db: Database, input: unknown, now = new Date()): Result<Code, CodeRefusal> {
102 const code = normalizeCode(input);
103 const r = code === undefined ? undefined : db.query<Row, [string]>("SELECT * FROM gift_code WHERE hash = ?1").get(hashCode(code));
104 if (!r) return err("unknown");
105 const c = fromRow(r);
106 const why = refusalOf(c, now);
107 return why === undefined ? ok(c) : err(why);
108 }
109
110 /** Spend one use, in one statement: the last use goes to one caller only. */
111 export function claimCode(db: Database, input: unknown, now = new Date()): Result<Code, CodeRefusal> {
112 const seen = lookupCode(db, input, now);
113 if (!seen.ok) return seen;
114 const done = db
115 .query(
116 `UPDATE gift_code SET used = used + 1
117 WHERE id = ?1 AND revoked_at IS NULL AND used < uses AND (expires_at IS NULL OR expires_at > ?2)`,
118 )
119 .run(seen.value.id, now.toISOString());
120 if (done.changes === 0) return lookupCode(db, input, now);
121 return ok({ ...seen.value, used: seen.value.used + 1 });
122 }
123
124 /** Give a use back: the forge it was for was not made. */
125 export function releaseCode(db: Database, id: string): void {
126 db.query("UPDATE gift_code SET used = MAX(0, used - 1) WHERE id = ?1").run(id);
127 }
128
129 export function noteRedeemed(db: Database, id: string, forge: string, now = new Date()): void {
130 db.query("INSERT INTO gift_redeem (code_id, forge, at) VALUES (?1, ?2, ?3)").run(id, forge, now.toISOString());
131 }
132
133 /** Revoke: no new use. Returns false when there is no such code or it was already revoked. */
134 export function revokeCode(db: Database, id: string, now = new Date()): boolean {
135 return db.query("UPDATE gift_code SET revoked_at = ?2 WHERE id = ?1 AND revoked_at IS NULL").run(id, now.toISOString()).changes > 0;
136 }
137
138 export interface Listed extends Code {
139 readonly madeByName: string;
140 readonly redeemed: readonly Redeemed[];
141 }
142
143 /** Newest first, with who redeemed each. */
144 export function listCodes(db: Database, limit = 200): Listed[] {
145 const rows = db
146 .query<Row & { name: string | null }, [number]>(
147 "SELECT g.*, u.username AS name FROM gift_code g LEFT JOIN user u ON u.id = g.made_by ORDER BY g.made_at DESC, g.rowid DESC LIMIT ?1",
148 )
149 .all(limit);
150 const redeemed = db.query<{ code_id: string; forge: string; at: string }, []>("SELECT code_id, forge, at FROM gift_redeem ORDER BY id").all();
151 return rows.map((r) => ({
152 ...fromRow(r),
153 madeByName: r.name ?? "an admin",
154 redeemed: redeemed.filter((x) => x.code_id === r.id).map((x) => ({ forge: x.forge, at: x.at })),
155 }));
156 }
157
158 const WINDOW_MS = 10 * 60_000;
159 /** Attempts one client may make in the window, redemption or look alike. */
160 export const TRIES_PER_WINDOW = 10;
161
162 /** Count one attempt for this client; false when it has spent its window. A refused attempt is not counted. */
163 export function takeTry(db: Database, bucket: string, now = Date.now()): boolean {
164 db.query("DELETE FROM gift_try WHERE at <= ?1").run(now - WINDOW_MS);
165 const n = db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM gift_try WHERE bucket = ?1").get(bucket)!.n;
166 if (n >= TRIES_PER_WINDOW) return false;
167 db.query("INSERT INTO gift_try (bucket, at) VALUES (?1, ?2)").run(bucket, now);
168 return true;
169 }
src/contract/openapi/routes/deleteApiV1CodesId.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/codes/{id}",
6 summary: "Revoke a gift code (a person admin's token, admin scope): no new uses; forges already made keep their plan",
7 auth: true,
8 role: "admin",
9 refusals: { not_found: "the token is not a person who administers the forge (404)", no_such_code: "no code has this id, or it was already revoked (404)" },
10 };
src/contract/openapi/routes/getApiV1Codes.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/codes",
6 summary: "The forge's gift codes (a person admin's token): each one's plan, note, uses and who redeemed it, newest first. Never the plain code. Answers { codes, plans }",
7 auth: true,
8 role: "admin",
9 refusals: { not_found: "the token is not a person who administers the forge (404)" },
10 };
src/contract/openapi/routes/index.ts +3
@@ -4,6 +4,7 @@ export { route as deleteApiV1AdminAccountsUsername } from "./deleteApiV1AdminAcc
44 export { route as deleteApiV1AdminInvitesId } from "./deleteApiV1AdminInvitesId.ts";
55 export { route as deleteApiV1AgentsUsername } from "./deleteApiV1AgentsUsername.ts";
66 export { route as deleteApiV1BoardsSlug } from "./deleteApiV1BoardsSlug.ts";
7 export { route as deleteApiV1CodesId } from "./deleteApiV1CodesId.ts";
78 export { route as deleteApiV1ConversationFilesId } from "./deleteApiV1ConversationFilesId.ts";
89 export { route as deleteApiV1ConversationQueueId } from "./deleteApiV1ConversationQueueId.ts";
910 export { route as deleteApiV1FeaturesSlug } from "./deleteApiV1FeaturesSlug.ts";
@@ -66,6 +67,7 @@ export { route as getApiV1BoardsSlugPanelsId } from "./getApiV1BoardsSlugPanelsI
6667 export { route as getApiV1BoardsSources } from "./getApiV1BoardsSources.ts";
6768 export { route as getApiV1Changelog } from "./getApiV1Changelog.ts";
6869 export { route as getApiV1CloudAccounts } from "./getApiV1CloudAccounts.ts";
70 export { route as getApiV1Codes } from "./getApiV1Codes.ts";
6971 export { route as getApiV1Contribute } from "./getApiV1Contribute.ts";
7072 export { route as getApiV1ConversationFilesId } from "./getApiV1ConversationFilesId.ts";
7173 export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts";
@@ -262,6 +264,7 @@ export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdV
262264 export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts";
263265 export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts";
264266 export { route as postApiV1Builds } from "./postApiV1Builds.ts";
267 export { route as postApiV1Codes } from "./postApiV1Codes.ts";
265268 export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts";
266269 export { route as postApiV1ConversationFiles } from "./postApiV1ConversationFiles.ts";
267270 export { route as postApiV1ConversationMessages } from "./postApiV1ConversationMessages.ts";
src/contract/openapi/routes/postApiV1Codes.ts added +11
@@ -0,0 +1,11 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/codes",
6 summary: "Make a gift code that gives a plan for free (a person admin's token, admin scope). dryRun is true unless false; the plain code and its /?code= link are in the answer that makes it, once",
7 auth: true,
8 role: "admin",
9 body: { plan: "a paid plan key", uses: "how many times, default 1", note: "optional, up to 80 characters", expires: "optional date or time", dryRun: "true unless false" },
10 refusals: { not_found: "the token is not a person who administers the forge (404)", bad_plan: "plan is not a paid plan (422)", bad_uses: "uses is not 1 to 1000 (422)", bad_note: "note is too long (422)", bad_expiry: "expires is not a future date (422)" },
11 };
src/contract/tags.ts +1
@@ -66,6 +66,7 @@ const ALIAS: Readonly<Record<string, string>> = {
6666 returns: "billing",
6767 credit: "billing",
6868 spend: "billing",
69 codes: "billing",
6970 usage: "billing",
7071 forge: "forges",
7172 passport: "user",
src/core/migration-pins/20261111000000-gift-codes.pin added +1
@@ -0,0 +1,1 @@
1 d5e714e51bd5ce15e84dc129f4baf97d117258f6ed16f93bb099c732619c4f2d
src/core/migrations/20261111000000-gift-codes.ts added +29
@@ -0,0 +1,29 @@
1 // Gift codes (#11157): a code gives a plan to the forge made with it. Stored
2 // hashed; the plain code is shown once to the admin. Additive. `gift_try`
3 // counts a client's attempts so codes cannot be guessed by trying.
4 export default `
5 CREATE TABLE gift_code (
6 id TEXT PRIMARY KEY,
7 hash TEXT NOT NULL UNIQUE,
8 plan TEXT NOT NULL,
9 note TEXT NOT NULL DEFAULT '',
10 uses INTEGER NOT NULL,
11 used INTEGER NOT NULL DEFAULT 0,
12 expires_at TEXT,
13 revoked_at TEXT,
14 made_by TEXT NOT NULL,
15 made_at TEXT NOT NULL
16 );
17 CREATE TABLE gift_redeem (
18 id INTEGER PRIMARY KEY AUTOINCREMENT,
19 code_id TEXT NOT NULL,
20 forge TEXT NOT NULL,
21 at TEXT NOT NULL
22 );
23 CREATE INDEX gift_redeem_code ON gift_redeem (code_id, id);
24 CREATE TABLE gift_try (
25 bucket TEXT NOT NULL,
26 at INTEGER NOT NULL
27 );
28 CREATE INDEX gift_try_bucket ON gift_try (bucket, at);
29 `;
src/crew/conversation_confirm.ts +1 −1
@@ -109,7 +109,7 @@ export const ASKING: Readonly<Record<string, Asking>> = {
109109 app_deploy: { does: "redeploy or stop an app's production or preview" },
110110 stop: { does: "stop a crew run" },
111111 converge: { does: "have a fork's crew merge the parent's updates" },
112 forges: { does: "grant a forge a plan by hand", asks: (a) => a["plan"] !== undefined && a["dryRun"] === false },
112 forges: { does: "grant a forge a plan by hand, or make or revoke a gift code", asks: (a) => (a["plan"] !== undefined || a["code"] !== undefined || a["revoke"] !== undefined) && a["dryRun"] === false },
113113 origins: { does: "sync an origin or replace the internal hosts one may live on", asks: not("action", "list", "repos") },
114114 cloud_account: { does: "change a cloud account" },
115115 pinned_forge: { does: "deploy, update or roll back a pinned forge on the owner's cloud", asks: not("verb", "list") },
src/http/api.ts +2
@@ -351,6 +351,7 @@ import { forgeRoutes } from "./api/forges.ts";
351351 import { packRoutes } from "./api/packs.ts";
352352 import { forgeViewRoutes } from "./api/forge_view.ts";
353353 import { billingRoutes } from "./api/billing.ts";
354 import { codesRoutes } from "./api/codes.ts";
354355 import { economicsRoutes } from "./api/economics.ts";
355356 import { releasesRoutes } from "./api/releases.ts";
356357 import { hookRoutes } from "./api/hooks.ts";
@@ -3587,6 +3588,7 @@ export function apiRoutes(router: Router<Handler>): void {
35873588 forgeViewRoutes(router);
35883589 economicsRoutes(router);
35893590 billingRoutes(router);
3591 codesRoutes(router);
35903592 releasesRoutes(router);
35913593
35923594 router.add("GET", "/api/v1/apps", (ctx) => {
src/http/api/codes.ts added +52
@@ -0,0 +1,52 @@
1 /**
2 * GET/POST /api/v1/codes and DELETE /api/v1/codes/{id}: the gift codes of
3 * `actions/codes.ts`, the twin of the Billing page's Codes section. A forge
4 * admin who is a person; anyone else is told 404. POST plans first (`dryRun`
5 * is true unless `false`); the plain code is in the answer that makes it and
6 * nowhere else. Same-origin checked.
7 */
8
9 import { CODE_ERRORS, makeCode, planCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts";
10 import { publicOrigin } from "../../core/public_origin.ts";
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import { crossOrigin, type Handler } from "../context.ts";
13 import { apiError, body, forbidden, json, notFound, requireScope } from "../respond.ts";
14 import type { Router } from "../router.ts";
15
16 const refuse = (e: CodeError): Response =>
17 e === "not_found" ? notFound() : apiError(e, e === "no_such_code" ? 404 : 422, CODE_ERRORS[e]);
18
19 export function codesRoutes(router: Router<Handler>): void {
20 router.add("GET", "/api/v1/codes", (ctx) => {
21 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
22 if (ctx.auth.user?.isAdmin !== true) return notFound();
23 const denied = requireScope(ctx, "ro");
24 if (denied) return denied;
25 const read = readCodes(ctx.app, ctx.auth.user);
26 return read.ok ? json(read.value, 200, { "cache-control": "private, no-store" }) : notFound();
27 });
28
29 router.add("POST", "/api/v1/codes", async (ctx) => {
30 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
31 if (ctx.auth.user?.isAdmin !== true) return notFound();
32 const denied = requireScope(ctx, "admin");
33 if (denied) return denied;
34 const input = await body(ctx.request);
35 if (input["dryRun"] !== undefined && typeof input["dryRun"] !== "boolean") return apiError("invalid", 422, "dryRun is true or false");
36 if (input["dryRun"] !== false) {
37 const planned = planCode(ctx.app, ctx.auth.user, input);
38 return planned.ok ? json({ dryRun: true, plan: planned.value, says: "This is the code; nothing was made. Send dryRun: false to make it." }) : refuse(planned.error);
39 }
40 const made = makeCode(ctx.app, ctx.auth.user, input, publicOrigin(ctx.app.config.url, ctx.url.origin));
41 return made.ok ? json({ dryRun: false, ...made.value, says: "Shown once: the plain code is not kept." }, 201, { "cache-control": "no-store" }) : refuse(made.error);
42 });
43
44 router.add("DELETE", "/api/v1/codes/:id", (ctx) => {
45 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
46 if (ctx.auth.user?.isAdmin !== true) return notFound();
47 const denied = requireScope(ctx, "admin");
48 if (denied) return denied;
49 const done = revoke(ctx.app, ctx.auth.user, ctx.params["id"] ?? "");
50 return done.ok ? json({ revoked: done.value.id }) : refuse(done.error);
51 });
52 }
src/http/mcp/tools/forges.ts +26 −3
@@ -1,20 +1,43 @@
11 import { BILLING_WORDS, grantPlan, planGrant, readPlan } from "../../../actions/billing.ts";
2 import { CODE_ERRORS, makeCode, planCode, readCodes, revoke } from "../../../actions/codes.ts";
3 import { publicOrigin } from "../../../core/public_origin.ts";
24 import { scopeAllows } from "../../../core/tokens.ts";
35 import { clientRows } from "../../../spawn/clients.ts";
46 import { good, bad, type Tool } from "../shared.ts";
57
68 export const tool: Tool = {
79 name: "forges",
8 doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed, stage (sent/opened/signed in/last tell; resend true when unopened 3 days: claim_link), last activity, reachable, size, use; `{ forge, plan?, dryRun? }` reads a forge's plan (small|medium|large, status, source, changes) or, with `plan`, grants it by hand, planned unless `dryRun` is false",
10 doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed, stage (sent/opened/signed in/last tell; resend true when unopened 3 days: claim_link), last activity, reachable, size, use; `{ forge, plan?, dryRun? }` reads a forge's plan (small|medium|large, status, source, changes) or, with `plan`, grants it by hand; gift codes: `codes` true lists, `code` {plan, uses?, note?, expires?} makes (shown once), `revoke` id; planned unless `dryRun` is false",
911 description:
1012 "Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant.",
11 inputSchema: { type: "object", properties: { forge: { type: "string" }, plan: { type: "string" }, dryRun: { type: "boolean" } } },
13 inputSchema: { type: "object", properties: { forge: { type: "string" }, plan: { type: "string" }, codes: { type: "boolean" }, code: { type: "object" }, revoke: { type: "string" }, dryRun: { type: "boolean" } } },
1214 writes: true,
1315 dryByDefault: true,
14 readsWithout: ["forge", "plan"],
16 readsWithout: ["forge", "plan", "code", "revoke"],
1517 async run(ctx, user, args) {
1618 if (!user.isAdmin)
1719 return bad("forbidden", "only an admin of this forge sees the forges it sent");
20 if (args["codes"] === true || args["code"] !== undefined || args["revoke"] !== undefined) {
21 const no = (e: string) => (e === "not_found" ? bad("not_found", "Not found.") : bad("invalid", CODE_ERRORS[e as keyof typeof CODE_ERRORS] ?? e));
22 if (args["code"] === undefined && args["revoke"] === undefined) {
23 const read = readCodes(ctx.app, user);
24 return read.ok ? good(read.value) : no(read.error);
25 }
26 const live = args["dryRun"] === false;
27 if (live && !scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "gift codes need an admin token");
28 if (args["revoke"] !== undefined) {
29 if (!live) return good({ says: "This would revoke the code; nothing changed. Send dryRun: false to do it." });
30 const done = revoke(ctx.app, user, args["revoke"]);
31 return done.ok ? good(done.value) : no(done.error);
32 }
33 const input = typeof args["code"] === "object" && args["code"] !== null ? (args["code"] as Record<string, unknown>) : {};
34 if (!live) {
35 const planned = planCode(ctx.app, user, input);
36 return planned.ok ? good({ plan: planned.value, says: "This is the code; nothing was made. Send dryRun: false to make it." }) : no(planned.error);
37 }
38 const made = makeCode(ctx.app, user, input, publicOrigin(ctx.app.config.url, ctx.url.origin));
39 return made.ok ? good({ code: made.value.code, link: made.value.link, says: "Shown once: the plain code is not kept." }) : no(made.error);
40 }
1841 if (args["forge"] !== undefined || args["plan"] !== undefined) {
1942 const refuse = (e: string) => (e === "not_found" ? bad("not_found", "Not found.") : bad("invalid", BILLING_WORDS[e as keyof typeof BILLING_WORDS] ?? e));
2043 if (args["plan"] === undefined) {
src/http/settings_routes.ts +3 −1
@@ -17,7 +17,7 @@ import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts
1717 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1818 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
1919 import { appTemplatesPage, appTemplatesSet } from "../web/pages/app_templates.ts";
20 import { billingPage, billingGrant } from "../web/pages/billing.ts";
20 import { billingPage, billingGrant, billingMakeCode, billingRevokeCode } from "../web/pages/billing.ts";
2121 import { economicsPage, economicsSet } from "../web/pages/economics.ts";
2222 import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts";
2323 import { usagePage } from "../web/pages/usage.ts";
@@ -208,6 +208,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
208208 router.add("POST", "/settings/economics", economicsSet);
209209 router.add("GET", "/settings/billing", billingPage);
210210 router.add("POST", "/settings/billing/grant", billingGrant);
211 router.add("POST", "/settings/billing/codes", billingMakeCode);
212 router.add("POST", "/settings/billing/codes/revoke", billingRevokeCode);
211213 router.add("GET", "/settings/flags", flagsPage);
212214 // Apps absorbed as the forge's own features, and their settings.
213215 router.add("GET", "/settings/captain", captainPage);
src/spawn/door.ts +41 −9
@@ -37,6 +37,8 @@ import { readdirSync, readFileSync } from "node:fs";
3737 import { readdir, stat } from "node:fs/promises";
3838 import { join } from "node:path";
3939 import { FREE, getPlan, getPlans, PAID_PLAN_ADMIN_ONLY } from "../settings/plans.ts";
40 import { giftOf, REDEEM_WORDS, settleGift, unclaimGift } from "../actions/codes.ts";
41 import { claimCode, CODE_WORDS, type Code } from "../billing/codes.ts";
4042
4143 export interface Door {
4244 readonly status: number;
@@ -260,11 +262,21 @@ export async function spawnDoor(
260262 // A plan (`settings/plans.ts`) gives the child its size; read now, so an edit governs this spawn.
261263 let planKey: string | undefined;
262264 let size = raw["size"];
265 // A gift code (`actions/codes.ts`) names the plan itself: no admin, no payment, whatever the provider.
266 const giftIn = raw["code"];
267 let giftSeen: { plan: string } | undefined;
268 if (giftIn !== undefined && giftIn !== null && giftIn !== "") {
269 if (raw["plan"] !== undefined && raw["plan"] !== null && raw["plan"] !== "") return invalid("code", "a code gives its own plan; send the code or a plan, not both");
270 const seen = giftOf(ctx.app, ctx.request, giftIn);
271 if (!seen.ok) return seen.error === "slow_down" ? { status: 429, body: { error: "rate_limited", message: REDEEM_WORDS.slow_down } } : invalid("code", REDEEM_WORDS[seen.error]);
272 raw = { ...raw, plan: seen.value.plan };
273 giftSeen = { plan: seen.value.plan };
274 }
263275 if (raw["plan"] !== undefined && raw["plan"] !== null && raw["plan"] !== "") {
264276 const chosen = typeof raw["plan"] === "string" ? getPlan(db, raw["plan"]) : undefined;
265277 if (chosen === undefined) return invalid("plan", `must be one of ${getPlans(db).map((p) => p.key).join(", ")}`);
266 if (anon && chosen.key !== FREE) return invalid("plan", `the ${chosen.name} plan is for a signed-in person; sign in first`);
267 if (chosen.key !== FREE && user?.isAdmin !== true) return invalid("plan", PAID_PLAN_ADMIN_ONLY(chosen.name));
278 if (anon && chosen.key !== FREE && giftIn === undefined) return invalid("plan", `the ${chosen.name} plan is for a signed-in person; sign in first`);

Non-blocking: the paid-plan checks are skipped when giftIn === undefined, but an empty or null code is not undefined, so {plan:"medium", code:""} skips the door's guard (spawnForge still refuses it, and a dry run would preview it). Key the bypass on giftSeen !== undefined so only a code that was actually looked up lends the plan.

279 if (chosen.key !== FREE && user?.isAdmin !== true && giftIn === undefined) return invalid("plan", PAID_PLAN_ADMIN_ONLY(chosen.name));
268280 if (size !== undefined && size !== null && size !== "" && size !== chosen.size)
269281 return invalid("size", `the ${chosen.name} plan is a ${chosen.size} forge; leave size out or send ${chosen.size}`);
270282 planKey = chosen.key;
@@ -299,6 +311,7 @@ export async function spawnDoor(
299311 plan: planned.value,
300312 // Who the child's first admin will be: the caller, or whoever claims it.
301313 firstAdmin: user?.username ?? null,
314 ...(giftSeen === undefined ? {} : { gift: giftSeen }),
302315 ...dryRun(planned.value, source),
303316 ...(planned.value.budgetUsd === null ? {} : { keyNote: KEY_BUDGET_NOTE }),
304317 },
@@ -316,14 +329,33 @@ export async function spawnDoor(
316329 },
317330 };
318331 }
332 // The use is spent just before the forge is made, in one statement; it goes back if no forge is made.
333 let gift: Code | undefined;
334 if (giftIn !== undefined && giftIn !== null && giftIn !== "") {
335 const claimed = claimCode(ctx.app.db, giftIn);
336 if (!claimed.ok) return invalid("code", CODE_WORDS[claimed.error]);
337 gift = claimed.value;
338 }
319339 const startedAt = Date.now();
320 const made = await spawnForge(ctx.app, user, input, source, {
321 placement: placementFor(ctx.app),
322 request: ctx.request,
323 url: ctx.url,
324 ...(typeof modelKey === "string" ? { modelKey } : {}),
325 ...(modelEndpoint === undefined ? {} : { modelEndpoint }),
326 });
340 // The use is given back unless the forge was made and the redemption recorded: a throw settles it too.
341 let settled = false;
342 let made: Awaited<ReturnType<typeof spawnForge>>;
343 try {
344 made = await spawnForge(ctx.app, user, input, source, {
345 ...(gift === undefined ? {} : { gift: true }),
346 placement: placementFor(ctx.app),
347 request: ctx.request,
348 url: ctx.url,
349 ...(typeof modelKey === "string" ? { modelKey } : {}),
350 ...(modelEndpoint === undefined ? {} : { modelEndpoint }),
351 });
352 if (made.ok && gift !== undefined && !made.value.existed) {
353 settleGift(ctx.app, gift, made.value.name);
354 settled = true;
355 }
356 } finally {
357 if (gift !== undefined && !settled) unclaimGift(ctx.app, gift);
358 }
327359 if (!made.ok) return spawnWords(made.error);
328360 if (anon && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request);
329361 // Its apps come up now, so the first visit after a claim finds them awake.
src/tui/screens/codes.ts added +97
@@ -0,0 +1,97 @@
1 /**
2 * Gift codes in the terminal, a forge admin's who is a person: the same
3 * `readCodes`, `makeCode` and `revoke` the page, the API and MCP use. `n`
4 * makes a code on the plan `p` has chosen and shows the plain code and its
5 * link once (it is kept only as a hash); `x` revokes the one under the cursor
6 * after a yes/no. Live: a code made or redeemed anywhere refreshes the list.
7 */
8
9 import { makeCode, readCodes, revoke, CODE_ERRORS } from "../../actions/codes.ts";
10 import { publicOrigin } from "../../core/public_origin.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import { fill, len, split } from "../ui/layout.ts";
14 import { style } from "../term/style.ts";
15 import { truncate } from "../term/width.ts";
16 import type { Session } from "../session.ts";
17 import { ListState, confirmScreen, drawList, live, yesNo, type ListRow } from "./common.ts";
18 import type { PaletteEntry } from "./entries.ts";
19
20 const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
21
22 export function codeRows(f: Session): { rows: ListRow[]; ids: string[]; plans: string[] } {
23 const read = readCodes(f.app, f.user);
24 if (!read.ok) return { rows: [], ids: [], plans: [] };
25 const rows = read.value.codes.map((c) => {
26 const state = c.revokedAt !== undefined ? "revoked" : c.used >= c.uses ? "used up" : "";
27 const who = c.redeemed.map((r) => r.forge).join(", ");
28 return { title: `${c.plan}${c.note === "" ? "" : ` · ${c.note}`}`, secondary: `${c.used} of ${c.uses} used${state === "" ? "" : ` · ${state}`}`, meta: who };
29 });
30 return { rows, ids: read.value.codes.map((c) => c.id), plans: read.value.plans };
31 }
32
33 export function codesScreen(f: Session, ctx: Ctx): Screen {
34 const state = new ListState();
35 let plan = 0;
36 let note = "";
37 const screen: Screen = {
38 title: "Gift codes",
39 keys: [binding(["j", "k"], "move cursor"), binding(["p"], "choose plan"), binding(["n"], "make a code"), binding(["x"], "revoke"), binding(["esc"], "back")],
40 async load(signal) {
41 live(f, signal, { where: (e) => e.kind.startsWith("billing.code.") }, () => ctx.invalidate());
42 },
43 render(area, s) {
44 const c = ctx.theme.c;
45 const { rows, plans } = codeRows(f);
46 state.clamp(rows.length);
47 const [head, body] = split(area, "v", [len(2), fill(1)]);
48 const chosen = plans[plan % Math.max(plans.length, 1)] ?? "";
49 s.text(head!.x, head!.y, truncate(note !== "" ? note : `Plan ${chosen}: n makes a code, p changes the plan`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
50 if (rows.length === 0) s.text(body!.x, body!.y, truncate("No code yet.", body!.w), style(c.mutedFg, c.bg), body!.x + body!.w);
51 else drawList(s, body!, c, state, rows);
52 },
53 key(e) {
54 const { rows, ids, plans } = codeRows(f);
55 if (e.key === "p" && plans.length > 0) {
56 plan = (plan + 1) % plans.length;
57 ctx.invalidate();
58 return true;
59 }
60 if (e.key === "n") {
61 const chosen = plans[plan % Math.max(plans.length, 1)];
62 const made = makeCode(f.app, f.user, { plan: chosen ?? "" }, publicOrigin(f.app.config.url, `http://localhost:${f.app.config.port}`));
63 note = made.ok ? `Shown once: ${made.value.code} ${made.value.link}` : made.error === "not_found" ? "Not found." : `${CODE_ERRORS[made.error]}.`;
64 ctx.invalidate();
65 return true;
66 }
67 const id = ids[state.index];
68 if (e.key === "x" && id !== undefined) {
69 const go = (i: number): void => {
70 if (i !== 0) return;
71 const done = revoke(f.app, f.user, id);
72 note = done.ok ? "Code revoked. Forges already made keep their plan." : "Not found.";
73 ctx.invalidate();
74 };
75 const ask = confirmScreen(ctx, screen, {
76 title: "Revoke a code",
77 body: [`Stop ${rows[state.index]!.title} working? Made forges keep their plan.`],
78 actions: [{ label: "Revoke" }, { label: "Cancel" }],
79 onChoose: go,
80 });
81 ctx.push(yesNo(ctx, ask, go));
82 return true;
83 }
84 return state.key(e, rows.length);
85 },
86 };
87 return screen;
88 }
89
90 /** Make, list and revoke gift codes. */
91 export const entry: PaletteEntry = {
92 id: "codes",
93 title: "Gift codes",
94 hint: "make a code that gives a plan for free, and revoke one",
95 offered: isAdmin,
96 run: (session, ctx) => ctx.push(codesScreen(session, ctx)),
97 };
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 codes } from "./codes.ts";
2122 import { entry as boards } from "./boards.ts";
2223 import { entry as drift } from "./drift.ts";
2324 import { entry as captain } from "./captain.ts";
@@ -135,6 +136,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
135136 economics,
136137 money,
137138 billing,
139 codes,
138140 appTemplates,
139141 devices,
140142 flowsAll,
src/web/pages/billing.ts +89 −4
@@ -6,23 +6,84 @@
66 */
77
88 import { BILLING_WORDS, grantPlan, readBilling, type BillingError } from "../../actions/billing.ts";
9 import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts";
10 import { publicOrigin } from "../../core/public_origin.ts";
911 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1012 import type { Ctx } from "../../http/context.ts";
1113 import { SIZES } from "../../spawn/sizes.ts";
1214 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
1315 import { field, readForm } from "../form.ts";
14 import { html } from "../html.ts";
16 import { html, type Html } from "../html.ts";
1517 import { flashTo, page } from "../layout.ts";
16 import { btn } from "../ui.ts";
18 import { btn, copyField, field as formField, selectField } from "../ui.ts";
1719 import { accountSections } from "./origins.ts";
1820
1921 const PATH = "/settings/billing";
2022 const words = (e: BillingError) => (e === "not_found" ? "Not found" : BILLING_WORDS[e]);
2123
24 /** A code just made: the plain code and its link, shown once and never kept. */
25 interface Shown {
26 readonly code: string;
27 readonly link: string;
28 }
29
30 const day = (iso: string): string => iso.slice(0, 10);
31
32 /** The Codes section: make one, copy it once, then one line per code with its one action. */
33 function codesSection(ctx: Ctx, shown: Shown | undefined, values: Record<string, string>, error: string | undefined): Html | null {
34 const read = readCodes(ctx.app, ctx.auth.user);
35 if (!read.ok) return null;
36 const { codes, plans } = read.value;
37 const lines = codes.map((c) => {
38 const state = c.revokedAt !== undefined ? "revoked" : c.expiresAt !== undefined && Date.parse(c.expiresAt) <= Date.now() ? "expired" : c.used >= c.uses ? "used up" : "";
39 const who = c.redeemed.map((r) => `${r.forge} ${day(r.at)}`).join(", ");
40 const redeemed = c.redeemed.length === 0 ? "not redeemed yet" : `redeemed by ${who}`;
41 return html`<li class="row" data-code="${c.id}">
42 <dl class="row-fields">
43 <dt class="sr-only">Gives</dt>
44 <dd><span class="mono">${c.plan}</span> <span class="muted small">${c.note}${c.expiresAt === undefined ? "" : ` · until ${day(c.expiresAt)}`}</span></dd>
45 <dt class="sr-only">Used</dt>
46 <dd class="small">${c.used} of ${c.uses} used${state === "" ? "" : ` · ${state}`} · <span class="muted">${redeemed}</span></dd>
47 </dl>
48 ${c.revokedAt === undefined
49 ? html`<form method="post" action="${PATH}/codes/revoke">
50 <input type="hidden" name="id" value="${c.id}" />
51 ${btn("Stop this code working", { size: "sm", label: `Stop the code for ${c.note || c.plan} from working; forges already made keep their plan` })}
52 </form>`
53 : null}
54 </li>`;
55 });
56 return html`<section class="settings-block" id="codes">
57 <h2>Codes</h2>
58 ${error === undefined ? null : html`<p class="error" role="alert">${error}</p>`}
59 ${shown === undefined
60 ? null
61 : html`<div class="stack stack-tight" id="new-code" role="status">
62 <p class="small">Shown once. It is kept only as a hash; send the link.</p>
63 ${copyField("gift-code", shown.code, "The code", "Copy the code")}
64 ${copyField("gift-link", shown.link, "The link to send", "Copy the link")}
65 </div>`}
66 <form class="row-fields" method="post" action="${PATH}/codes" id="make-code">
67 ${selectField({ label: "Plan it gives", name: "plan", options: plans.map((p) => ({ value: p, label: p, selected: p === (values["plan"] ?? plans[0]) })) })}
68 ${formField({ label: "Uses", name: "uses", type: "number", value: values["uses"] ?? "1" })}
69 ${formField({ label: "Note", name: "note", value: values["note"] ?? "", hint: "For Mark" })}
70 ${formField({ label: "Expires", name: "expires", type: "date", value: values["expires"] ?? "" })}
71 ${btn("Make a code", { variant: "primary" })}
72 </form>
73 ${lines.length === 0 ? html`<p class="muted small">No code yet.</p>` : html`<ul class="list setting-list">${lines}</ul>`}
74 </section>`;
75 }
76
77 const codeWords = (e: CodeError): string => (e === "not_found" ? "Not found" : CODE_ERRORS[e]);
78
2279 // GET /settings/billing
2380 export function billingPage(ctx: Ctx): Response {
2481 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2582 if (!ctx.auth.user) return signInFirst(ctx);
83 return billingView(ctx);
84 }
85
86 function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {}, error?: string, status = 200): Response {
2687 const read = readBilling(ctx.app, ctx.auth.user);
2788 if (!read.ok) return notFoundPage(ctx);
2889 const b = read.value;
@@ -46,17 +107,41 @@ export function billingPage(ctx: Ctx): Response {
46107 ${rows.length === 0
47108 ? html`<p class="muted">No forge has been sent from here.</p>`
48109 : html`<ul class="list setting-list">${rows}</ul>`}
49 </section>`;
110 </section>
111 ${codesSection(ctx, shown, values, error)}`;
50112 const response = page({
51113 ctx,
52114 title: "Billing",
53115 body: html`<div class="page-head"><h1>Billing</h1></div>
54116 ${settingsLayout(accountSections("billing", { admin: true }), body, "Account settings")}`,
55 });
117 }, status);
56118 notePhase(ctx.request, "render", performance.now() - rendering);
119 // A code just made is in this body only: never kept by a cache.
120 if (shown !== undefined) response.headers.set("cache-control", "no-store");
57121 return response;
58122 }
59123
124 // POST /settings/billing/codes
125 export async function billingMakeCode(ctx: Ctx): Promise<Response> {
126 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
127 const form = await readForm(ctx, "admin");
128 if (form instanceof Response) return form;
129 const values = { plan: field(form, "plan"), uses: field(form, "uses"), note: field(form, "note"), expires: field(form, "expires") };
130 const made = makeCode(ctx.app, ctx.auth.user, values, publicOrigin(ctx.app.config.url, ctx.url.origin));
131 if (!made.ok) return made.error === "not_found" ? notFoundPage(ctx) : billingView(ctx, undefined, values, `${codeWords(made.error)}.`, 422);
132 return billingView(ctx, { code: made.value.code, link: made.value.link });
133 }
134
135 // POST /settings/billing/codes/revoke
136 export async function billingRevokeCode(ctx: Ctx): Promise<Response> {
137 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
138 const form = await readForm(ctx, "admin");
139 if (form instanceof Response) return form;
140 const done = revoke(ctx.app, ctx.auth.user, field(form, "id"));
141 if (!done.ok && done.error === "not_found") return notFoundPage(ctx);
142 return flashTo(ctx, `${PATH}#codes`, done.ok ? { message: "Code revoked. Forges already made keep their plan." } : { tone: "danger", message: `${codeWords(done.error)}.` });
143 }
144
60145 // POST /settings/billing/grant
61146 export async function billingGrant(ctx: Ctx): Promise<Response> {
62147 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
src/web/pages/new-forge.ts +36 −1
@@ -15,6 +15,7 @@ import { listReposVisibleTo } from "../../core/repos.ts";
1515 import type { User } from "../../core/users.ts";
1616 import type { Ctx } from "../../http/context.ts";
1717 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
18 import { giftOf, REDEEM_WORDS } from "../../actions/codes.ts";
1819 import { quotaFull } from "../../spawn/quota.ts";
1920 import { childHost, childUrlOf, noRoom, spawnDoor, type Door } from "../../spawn/door.ts";
2021 import {
@@ -28,6 +29,7 @@ import {
2829 import { DEFAULT_SIZE, SIZES, sizesView } from "../../spawn/sizes.ts";
2930 import { DEFAULT_TEMPLATE, TEMPLATES, templateOf } from "../../spawn/templates.ts";
3031 import { FOLLOW_LABELS, FOLLOW_WORDS } from "../../spawn/follow_words.ts";
32 import { dropGift, keepGift, keptGift } from "../gift_cookie.ts";
3133 import { notFoundPage, pageHead } from "../chrome.ts";
3234 import { field, readForm, type Form } from "../form.ts";
3335 import { html, type Html } from "../html.ts";
@@ -48,6 +50,8 @@ interface Values {
4850 readonly modelEndpoint: string;
4951 readonly size: string;
5052 readonly template: string;
53 /** A gift code (`actions/codes.ts`): it names the plan, so no checkout and no card. */
54 readonly code: string;
5155 /** The last post carried a key: it is never sent back, so it is asked for again. */
5256 readonly hadKey: boolean;
5357 }
@@ -65,6 +69,7 @@ const EMPTY: Values = {
6569 modelEndpoint: "",
6670 size: DEFAULT_SIZE,
6771 template: DEFAULT_TEMPLATE,
72 code: "",
6873 hadKey: false,
6974 };
7075
@@ -344,6 +349,7 @@ function formView(
344349 Never copied: ${LEFT_BEHIND.map(plainWords).join(", ")}. The child mints its own.
345350 </p>
346351 </details>
352 ${codeField(values.code, errors["code"])}
347353 <div class="form-actions">
348354 <button class="btn" type="submit" name="do" value="plan">
349355 See the plan
@@ -372,6 +378,7 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record
372378 <ul class="mb">
373379 <li>A forge at <b>${address}</b>.</li>
374380 <li>Whoever opens the link becomes its admin.</li>
381 ${giftLine(body["gift"])}
375382 ${user === undefined
376383 ? null
377384 : html`<li>
@@ -399,6 +406,16 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record
399406 </section>`;
400407 }
401408
409 /** "A gift: a <plan> forge, free." */
410 function giftWords(g: { plan: string }): string {
411 return `A gift: a ${g.plan} forge, free.`;
412 }
413
414 function giftLine(g: unknown): Html | null {
415 const gift = g as { plan?: unknown } | undefined;
416 return typeof gift?.plan === "string" ? html`<li><b>${giftWords({ plan: gift.plan })}</b></li>` : null;
417 }
418
402419 /** The answer to a spawn: the link, once. */
403420 /** The claim link, shown once: what to do with it, and a way to copy it. */
404421 export function claimLinkBody(link: string, note?: string): Html {
@@ -462,10 +479,18 @@ export function newForgePage(ctx: Ctx): Response {
462479 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
463480 const reads = readsFor(ctx, user);
464481 const rendering = performance.now();
482 // A code in the address moves to a cookie and out of the address (the log and history never hold it).
483 const inUrl = ctx.url.searchParams.get("code");
484 if (inUrl !== null && inUrl !== "") return keepGift(inUrl.slice(0, 64), "/forges/new", ctx.url, ctx.app.config.url);
485 // A link with a code arrives filled in, and says it is a gift, or plainly why not.
486 const code = keptGift(ctx.request);
487 const seen = code === "" ? undefined : giftOf(ctx.app, ctx.request, code);
488 const errors: Errors = seen !== undefined && !seen.ok ? { code: `${REDEEM_WORDS[seen.error]}.` } : {};
489 const gift = seen?.ok ? html`<p class="small" id="gift" role="status"><b>${giftWords(seen.value)}</b></p>` : null;
465490 const response = page({
466491 ctx,
467492 title: "New forge",
468 body: formView(ctx, user, { ...EMPTY, name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), ...fromPack(ctx, user) }, {}, null, reads),
493 body: formView(ctx, user, { ...EMPTY, code, name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), ...fromPack(ctx, user) }, errors, gift, reads),
469494 });
470495 notePhase(ctx.request, "render", performance.now() - rendering);
471496 return response;
@@ -491,6 +516,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
491516 modelEndpoint: field(form, "modelEndpoint").trim(),
492517 size: field(form, "size").trim() || DEFAULT_SIZE,
493518 template: field(form, "template").trim() || DEFAULT_TEMPLATE,
519 code: field(form, "code").trim().slice(0, 64),
494520 hadKey: field(form, "modelKey").trim() !== "",
495521 };
496522 const key = field(form, "modelKey").trim();
@@ -510,6 +536,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
510536 // Small is the default: it is not recorded, so the plan stays as it was.
511537 ...(values.size === DEFAULT_SIZE ? {} : { size: values.size }),
512538 ...(values.template === DEFAULT_TEMPLATE ? {} : { template: values.template }),
539 ...(values.code === "" ? {} : { code: values.code }),
513540 dryRun: planOnly,
514541 });
515542 if (door.status >= 400)
@@ -533,5 +560,13 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
533560 );
534561 // The link is in this body only: never kept by a cache.
535562 response.headers.set("cache-control", "no-store");
563 if (values.code !== "") response.headers.append("set-cookie", dropGift(ctx.url, ctx.app.config.url));
536564 return response;
537565 }
566
567 /** The code is asked for only when one is in play: filled in from a link, or behind a quiet disclosure that needs no script. */
568 function codeField(value: string, error: string | undefined): Html {
569 const input = formField({ label: "Gift code", name: "code", value, autocomplete: "off", ...(error ? { error } : {}) });
570 if (value !== "" || error) return input;
571 return html`<details><summary class="small">Have a code?</summary>${input}</details>`;
572 }
src/web/pages/site.ts +4
@@ -9,6 +9,7 @@
99 */
1010
1111 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
12 import { keepGift } from "../gift_cookie.ts";
1213 import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts";
1314 import { NOT_A_MEMBER } from "../../core/roles.ts";
1415 import { storageLimitMessage } from "../../core/storage.ts";
@@ -213,6 +214,9 @@ export function terminalCard(ctx: Ctx): Html {
213214 // the feed (`studioHome`, #599), whatever the mode (#625).
214215 export async function home(ctx: Ctx): Promise<Response> {
215216 const user = ctx.auth.user;
217 // A gift link (`/?code=`) keeps the code in a cookie and opens the new-forge form, so the next address has no code in it (#11157).
218 const gift = ctx.url.searchParams.get("code");
219 if (gift !== null && gift !== "") return keepGift(gift.slice(0, 64), "/forges/new", ctx.url, ctx.app.config.url);
216220 const res = user === undefined ? await studioDoor(ctx) : await studioHome(ctx, user);
217221 // RFC 9727 §3: where an agent that knows only the origin finds the API catalog.
218222 res.headers.append("link", API_CATALOG_LINK);
test/codes-tui.test.ts added +93
@@ -0,0 +1,93 @@
1 /** Gift codes in the terminal (#11157): an admin makes, sees and revokes; no one else is offered it. */
2 import { afterAll, beforeAll, 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 { createUser, type User } from "../src/core/users.ts";
8 import { createForgeApp } from "../src/tui/forge.ts";
9 import { codesScreen, entry } from "../src/tui/screens/codes.ts";
10 import { headlessTerminal } from "../src/tui/term/terminal.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12 import { settleApp } from "./helpers/tui.ts";
13
14 quietLogs();
15
16 let dir: string;
17 let app: App;
18 let alice: User;
19 let bob: User;
20
21 beforeAll(async () => {
22 dir = await mkdtemp(join(tmpdir(), "forge-codes-tui-"));
23 app = createApp({ dataDir: dir, port: 0, forgeRepo: "alice/forge-code" });
24 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
25 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
26 if (!a.ok || !b.ok) throw new Error("user");
27 alice = a.value;
28 bob = b.value;
29 });
30 afterAll(async () => {
31 app.close();
32 await rm(dir, { recursive: true, force: true });
33 });
34
35 const key = (k: string) => ({ type: "key" as const, key: k, ctrl: false, alt: false, shift: false });
36 function open(user: User) {
37 const { app: tui, session } = createForgeApp(
38 app,
39 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
40 { terminal: headlessTerminal(140, 40), exitHooks: false },
41 );
42 tui.start();
43 return { tui, session };
44 }
45 const count = () => app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM gift_code").get()!.n;
46
47 test("the entry is offered to an admin only", () => {
48 expect(entry.offered(open(alice).session)).toBe(true);
49 expect(entry.offered(open(bob).session)).toBe(false);
50 });
51
52 test("the admin makes a code, sees it once with its link, and revokes it after a yes", async () => {
53 const { tui, session } = open(alice);
54 tui.ctx.push(codesScreen(session, tui.ctx));
55 await settleApp(tui);
56 expect(tui.screen.toText()).toContain("No code yet.");
57 tui.dispatch(key("n"));
58 await settleApp(tui);
59 expect(count()).toBe(1);
60 expect(tui.screen.toText()).toMatch(/forge-[a-z0-9]{4}-[a-z0-9]{4}/);
61 expect(tui.screen.toText()).toContain("0 of 1 used");
62 tui.dispatch(key("x"));
63 await settleApp(tui);
64 expect(tui.screen.toText()).toContain("Made forges keep their plan");
65 tui.dispatch(key("y"));
66 await settleApp(tui);
67 expect(tui.screen.toText()).toContain("revoked");
68 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM gift_code WHERE revoked_at IS NOT NULL").get()!.n).toBe(1);
69 tui.stop?.();
70 });
71
72 test("a code made elsewhere shows up live", async () => {
73 const { tui, session } = open(alice);
74 tui.ctx.push(codesScreen(session, tui.ctx));
75 await settleApp(tui);
76 const { makeCode } = await import("../src/actions/codes.ts");
77 expect(makeCode(app, alice, { plan: "large", note: "elsewhere" }, "http://x").ok).toBe(true);
78 await new Promise((r) => setTimeout(r, 150));
79 await settleApp(tui);
80 expect(tui.screen.toText()).toContain("elsewhere");
81 tui.stop?.();
82 });
83
84 test("a non-admin who reaches the screen makes nothing", async () => {
85 const before = count();
86 const { tui, session } = open(bob);
87 tui.ctx.push(codesScreen(session, tui.ctx));
88 await settleApp(tui);
89 tui.dispatch(key("n"));
90 await settleApp(tui);
91 expect(count()).toBe(before);
92 tui.stop?.();
93 });
test/gift-codes.test.ts added +319
@@ -0,0 +1,319 @@
1 /**
2 * Gift codes (#11157): an admin makes one, a visitor redeems it and the forge
3 * is made on its plan with an entitlement sourced `code:<id>`; one-use,
4 * revoked, expired and wrong codes are refused; redemption is rate-limited;
5 * every make, redeem and revoke is audited; nothing calls a payment provider.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { entitlementOf, getStored } from "../src/billing/store.ts";
14 import { hashCode, normalizeCode } from "../src/billing/codes.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser } from "../src/core/users.ts";
17 import { ok } from "../src/kernel/result.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { bootHostedChild } from "../src/spawn/host.ts";
20 import { usePlacement, useProbe } from "../src/spawn/door.ts";
21 import { resetAnonSpawns } from "../src/spawn/rate.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 quietLogs();
25
26 describe("gift codes", () => {
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let admin = "";
31 let adminRo = "";
32 let bob = "";
33 let n = 0;
34 const made: string[] = [];
35
36 const send = (method: string, path: string, token: string | undefined, payload?: unknown, origin = true) =>
37 fetch(`${servers.url}${path}`, {
38 method,
39 redirect: "manual",
40 headers: {
41 "content-type": "application/json",
42 ...(token ? { authorization: `Bearer ${token}` } : {}),
43 ...(origin ? {} : { origin: "https://evil.example" }),
44 },
45 ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
46 });
47 const form = (path: string, token: string, fields: Record<string, string>) =>
48 fetch(`${servers.url}${path}`, {
49 method: "POST",
50 redirect: "manual",
51 headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
52 body: new URLSearchParams(fields),
53 });
54 const mcp = async (token: string, args: unknown) => {
55 const res = await fetch(`${servers.url}/mcp`, {
56 method: "POST",
57 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
58 body: JSON.stringify({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: "forges", arguments: args } }),
59 });
60 const text = await res.text();
61 const line = text.startsWith("event") ? text.split("\n").find((l) => l.startsWith("data:"))!.slice(5) : text;
62 return JSON.parse(line).result;
63 };
64 const fresh = () => {
65 app.db.exec("DELETE FROM gift_try");
66 resetAnonSpawns(app.db);
67 };
68 const make = async (body: Record<string, unknown> = {}) => {
69 const res = await send("POST", "/api/v1/codes", admin, { plan: "medium", dryRun: false, ...body });
70 expect(res.status).toBe(201);
71 return (await res.json()) as { code: string; link: string; made: { id: string } };
72 };
73 const spawn = (token: string | undefined, name: string, code: unknown, extra: Record<string, unknown> = {}) => {
74 made.push(name);
75 return send("POST", "/api/v1/forges", token, { name, dryRun: false, code, ...extra });
76 };
77
78 beforeAll(async () => {
79 dataDir = await mkdtemp(join(tmpdir(), "forge-gift-"));
80 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge-code" });
81 servers = await serve(app);
82 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
83 if (!a.ok) throw new Error("user");
84 admin = createToken(app.db, a.value.id, "admin", "admin").token;
85 adminRo = createToken(app.db, a.value.id, "ro", "ro").token;
86 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
87 if (!b.ok) throw new Error("user");
88 bob = createToken(app.db, b.value.id, "admin", "admin").token;
89 const code = await createRepo(app, a.value, { name: "forge-code", visibility: "public" });
90 if (!code.ok) throw new Error("repo");
91 await commitToBranch(app, a.value, code.value, { message: "v1", files: [{ path: "src/main.ts", content: "1" }] });
92 useProbe(app, () => async () => true);
93 usePlacement(app, { place: async (p) => ok({ url: `https://${p.name}.fake.test`, bodyId: `fake-${p.name}` }), retire: async () => ok() });
94 }, 30_000);
95
96 afterAll(async () => {
97 for (const name of made) await bootHostedChild(app, name);
98 servers.stop();
99 app.close();
100 await rm(dataDir, { recursive: true, force: true });
101 });
102
103 test("a code is short, typed easily, and kept only as a hash", async () => {
104 const planned = await send("POST", "/api/v1/codes", admin, { plan: "medium" });
105 expect(await planned.json()).toMatchObject({ dryRun: true, plan: { plan: "medium", uses: 1, note: "" } });
106 expect(app.db.query("SELECT COUNT(*) AS n FROM gift_code").get()).toEqual({ n: 0 });
107 const m = await make({ note: "for Mark" });
108 expect(m.code).toMatch(/^forge-[a-z0-9]{4}-[a-z0-9]{4}$/);
109 expect(m.link).toBe(`${servers.url}/?code=${m.code}`);
110 const row = app.db.query<{ hash: string }, []>("SELECT * FROM gift_code").get()!;
111 expect(row.hash).toBe(hashCode(m.code));
112 expect(JSON.stringify(app.db.query("SELECT * FROM gift_code").all())).not.toContain(m.code);
113 expect(normalizeCode(` ${m.code.toUpperCase()} `)).toBe(m.code);
114 expect(normalizeCode("forge-xx")).toBeUndefined();
115 const list = await (await send("GET", "/api/v1/codes", admin)).text();
116 expect(list).not.toContain(m.code);
117 expect(list).toContain("for Mark");
118 });
119
120 test("make validates, and only a person admin may", async () => {
121 expect((await send("POST", "/api/v1/codes", admin, { plan: "free" })).status).toBe(422);
122 expect((await send("POST", "/api/v1/codes", admin, { plan: "huge" })).status).toBe(422);
123 expect((await send("POST", "/api/v1/codes", admin, { plan: "small", uses: 0 })).status).toBe(422);
124 expect((await send("POST", "/api/v1/codes", admin, { plan: "small", note: "x".repeat(81) })).status).toBe(422);
125 expect((await send("POST", "/api/v1/codes", admin, { plan: "small", note: "a\nb" })).status).toBe(422);
126 expect((await send("POST", "/api/v1/codes", admin, { plan: "small", expires: "2001-01-01" })).status).toBe(422);
127 expect((await send("POST", "/api/v1/codes", bob, { plan: "small", dryRun: false })).status).toBe(404);
128 expect((await send("POST", "/api/v1/codes", adminRo, { plan: "small", dryRun: false })).status).toBeGreaterThanOrEqual(403);
129 expect((await send("POST", "/api/v1/codes", admin, { plan: "small", dryRun: false }, false)).status).toBe(403);
130 expect((await send("GET", "/api/v1/codes", bob)).status).toBe(404);
131 });
132
133 test("make, redeem: the forge is on that plan, sourced code:<id>, with no payment call", async () => {
134 fresh();
135 const m = await make({ plan: "medium", note: "for Mark" });
136 const seen = await (await send("POST", "/api/v1/forges", bob, { name: "gifted", code: m.code })).json();
137 expect(seen).toMatchObject({ dryRun: true, gift: { plan: "medium" } });
138 expect(JSON.stringify(seen)).not.toContain("alice");
139 expect(app.db.query("SELECT used FROM gift_code WHERE id = ?").get(m.made.id)).toEqual({ used: 0 });
140 const real = globalThis.fetch;
141 let outside = 0;
142 globalThis.fetch = ((input: any, init?: any) => {
143 if (!String(input?.url ?? input).startsWith(servers.url)) outside++;
144 return real(input, init);
145 }) as typeof fetch;
146 let res: Response;
147 try {
148 res = await spawn(bob, "gifted", m.code);
149 } finally {
150 globalThis.fetch = real;
151 }
152 expect(res.status).toBe(201);
153 expect(outside).toBe(0);
154 expect(entitlementOf(app.db, "gifted", "small")).toEqual({ forge: "gifted", plan: "medium", status: "active", source: `code:${m.made.id}` });
155 expect(getStored(app.db, "gifted")?.provider).toBe("none");
156 const list = (await (await send("GET", "/api/v1/codes", admin)).json()) as any;
157 const row = list.codes.find((c: any) => c.id === m.made.id);
158 expect(row).toMatchObject({ used: 1, uses: 1, redeemed: [{ forge: "gifted" }] });
159 const kinds = app.db.query<{ kind: string }, []>("SELECT kind FROM event WHERE kind LIKE 'billing.code.%'").all().map((e) => e.kind);
160 expect(kinds).toContain("billing.code.make");
161 expect(kinds).toContain("billing.code.redeem");
162 });
163
164 test("a stranger redeems too, and a one-use code is refused the second time", async () => {
165 fresh();
166 const m = await make({ plan: "large" });
167 expect((await spawn(undefined, "stranger-one", m.code)).status).toBe(201);
168 expect(entitlementOf(app.db, "stranger-one", "small").plan).toBe("large");
169 const again = await spawn(bob, "stranger-two", m.code);
170 expect(again.status).toBe(422);
171 expect(JSON.stringify(await again.json())).toContain("used as many times");
172 expect(getStored(app.db, "stranger-two")).toBeUndefined();
173 });
174
175 test("a code with three uses lets three forges through, then says so", async () => {
176 fresh();
177 const m = await make({ plan: "small", uses: 3 });
178 for (const name of ["multi-a", "multi-b", "multi-c"]) expect((await spawn(bob, name, m.code)).status).toBe(201);
179 expect((await spawn(bob, "multi-d", m.code)).status).toBe(422);
180 });
181
182 test("a forge that fails to be made gives the use back", async () => {
183 fresh();
184 const m = await make({ plan: "small" });
185 expect((await spawn(bob, "Bad Name!", m.code)).status).toBe(422);
186 expect(app.db.query("SELECT used FROM gift_code WHERE id = ?").get(m.made.id)).toEqual({ used: 0 });
187 expect((await spawn(bob, "after-bad", m.code)).status).toBe(201);
188 });
189
190 test("two redeems race for the last use: exactly one forge is made", async () => {
191 fresh();
192 const m = await make({ plan: "small", uses: 1 });
193 const [a, b] = await Promise.all([spawn(bob, "race-a", m.code), spawn(bob, "race-b", m.code)]);
194 expect([a.status, b.status].sort()).toEqual([201, 422]);
195 expect(app.db.query("SELECT used FROM gift_code WHERE id = ?").get(m.made.id)).toEqual({ used: 1 });
196 expect(app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM gift_redeem WHERE code_id = ?").get(m.made.id)!.n).toBe(1);
197 });
198
199 test("a forge whose making throws gives the use back, and the code works again", async () => {
200 fresh();
201 const m = await make({ plan: "small" });
202 usePlacement(app, { place: async () => { throw new Error("placement crashed"); }, retire: async () => ok() });
203 const crashed = await spawn(bob, "throws-once", m.code).catch(() => undefined);
204 expect(crashed === undefined || crashed.status >= 400).toBe(true);
205 expect(app.db.query("SELECT used FROM gift_code WHERE id = ?").get(m.made.id)).toEqual({ used: 0 });
206 usePlacement(app, { place: async (p) => ok({ url: `https://${p.name}.fake.test`, bodyId: `fake-${p.name}` }), retire: async () => ok() });
207 expect((await spawn(bob, "after-throw", m.code)).status).toBe(201);
208 });
209
210 test("revoked and expired codes are refused; forges already made keep their plan", async () => {
211 fresh();
212 const r = await make({ plan: "large", uses: 5 });
213 expect((await spawn(bob, "keeps-plan", r.code)).status).toBe(201);
214 expect((await send("DELETE", `/api/v1/codes/${r.made.id}`, bob)).status).toBe(404);
215 expect((await send("DELETE", `/api/v1/codes/${r.made.id}`, admin)).status).toBe(200);
216 expect((await send("DELETE", `/api/v1/codes/${r.made.id}`, admin)).status).toBe(404);
217 const refused = await spawn(bob, "after-revoke", r.code);
218 expect(refused.status).toBe(422);
219 expect(JSON.stringify(await refused.json())).toContain("cancelled");
220 expect(entitlementOf(app.db, "keeps-plan", "small").plan).toBe("large");
221
222 const e = await make({ plan: "small", expires: new Date(Date.now() + 86_400_000 * 3).toISOString().slice(0, 10) });
223 app.db.query("UPDATE gift_code SET expires_at = '2020-01-01T00:00:00.000Z' WHERE id = ?").run(e.made.id);
224 const gone = await spawn(bob, "after-expiry", e.code);
225 expect(gone.status).toBe(422);
226 expect(JSON.stringify(await gone.json())).toContain("expired");
227 expect((await spawn(bob, "after-wrong", "forge-aaaa-bbbb")).status).toBe(422);
228 const kinds = app.db.query<{ kind: string }, []>("SELECT kind FROM event WHERE kind = 'billing.code.revoke'").all();
229 expect(kinds.length).toBe(1);
230 });
231
232 test("redemption is rate-limited per client, so codes cannot be guessed by trying", async () => {
233 fresh();
234 const seen: number[] = [];
235 for (let i = 0; i < 12; i++) seen.push((await send("POST", "/api/v1/forges", bob, { name: "guess", code: `forge-aaaa-${i.toString().padStart(4, "b")}` })).status);
236 expect(seen.slice(0, 10).every((x) => x === 422)).toBe(true);
237 expect(seen[10]).toBe(429);
238 expect(seen[11]).toBe(429);
239 fresh();
240 });
241
242 test("a code and a plan together are refused: the code names its own", async () => {
243 fresh();
244 const m = await make({ plan: "small" });
245 expect((await send("POST", "/api/v1/forges", bob, { name: "both", code: m.code, plan: "large" })).status).toBe(422);
246 });
247
248 test("MCP forges plans a code first, then makes and revokes it with dryRun false", async () => {
249 fresh();
250 const plan = await mcp(admin, { code: { plan: "small", note: "mcp" } });
251 expect(JSON.stringify(plan)).toContain("nothing was made");
252 expect(app.db.query("SELECT COUNT(*) AS n FROM gift_code WHERE note = 'mcp'").get()).toEqual({ n: 0 });
253 const live = await mcp(admin, { code: { plan: "small", note: "mcp" }, dryRun: false });
254 expect(JSON.stringify(live)).toContain("/?code=forge-");
255 const id = app.db.query<{ id: string }, []>("SELECT id FROM gift_code WHERE note = 'mcp'").get()!.id;
256 expect(JSON.stringify(await mcp(admin, { codes: true }))).toContain("mcp");
257 await mcp(admin, { revoke: id });
258 expect(app.db.query("SELECT revoked_at FROM gift_code WHERE id = ?").get(id)).toEqual({ revoked_at: null });
259 await mcp(admin, { revoke: id, dryRun: false });
260 expect(app.db.query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM gift_code WHERE id = ?").get(id)!.revoked_at).not.toBeNull();
261 await mcp(bob, { code: { plan: "small" }, dryRun: false });
262 expect(JSON.stringify(await mcp(bob, { code: { plan: "small" }, dryRun: false }))).not.toContain("/?code=");
263 });
264
265 test("the Billing page makes a code once, lists it first, and revokes it; the link fills the form", async () => {
266 fresh();
267 const page = await (await send("GET", "/settings/billing", admin)).text();
268 expect(page.indexOf("<h2>Plans</h2>")).toBeGreaterThan(0);
269 expect(page.indexOf("<h2>Plans</h2>")).toBeLessThan(page.indexOf('id="codes"'));
270 const res = await form("/settings/billing/codes", admin, { plan: "medium", uses: "2", note: "for <Mark>", expires: "" });
271 expect(res.status).toBe(200);
272 expect(res.headers.get("cache-control")).toBe("no-store");
273 const text = await res.text();
274 const code = /forge-[a-z0-9]{4}-[a-z0-9]{4}/.exec(text)![0];
275 expect(text).toContain(`/?code=${code}`);
276 expect(text).toContain("for &lt;Mark&gt;");
277 expect(text).toContain("Stop this code working");
278 expect(await (await send("GET", "/settings/billing", admin)).text()).not.toContain(code);
279 expect((await form("/settings/billing/codes", admin, { plan: "free", uses: "1" })).status).toBe(422);
280 expect((await form("/settings/billing/codes", bob, { plan: "small" })).status).toBe(404);
281
282 const home = await send("GET", `/?code=${code}`, undefined);
283 expect(home.status).toBe(303);
284 expect(home.headers.get("location")).toBe("/forges/new");
285 const jar = home.headers.get("set-cookie")!;
286 expect(jar).toContain("HttpOnly");
287 const cookie = jar.split(";")[0]!;
288 const seeNew = (c?: string) => fetch(`${servers.url}/forges/new`, { redirect: "manual", headers: c ? { cookie: c } : {} });
289 // The same code in the new-forge address also leaves the address.
290 const moved = await send("GET", `/forges/new?code=${code}`, undefined);
291 expect(moved.status).toBe(303);
292 expect(moved.headers.get("location")).toBe("/forges/new");
293 const gift = await (await seeNew(cookie)).text();
294 expect(gift).toContain("A gift: a medium forge, free.");
295 expect(gift).not.toContain("alice:");
296 expect(gift).toContain(`value="${code}"`);
297 expect(gift).not.toContain("Have a code?");
298 const bare = await (await seeNew()).text();
299 expect(bare).toContain("Have a code?");
300 expect(bare).not.toContain("A gift:");
301 const wrongText = await (await seeNew("forge_gift=forge-zzzz-zzzz")).text();
302 expect(wrongText).toContain("That code is not one this forge knows");
303 expect(wrongText).toContain('id="new-forge"');
304
305 const id = app.db.query<{ id: string }, [string]>("SELECT id FROM gift_code WHERE hash = ?").get(hashCode(code))!.id;
306 const rev = await form("/settings/billing/codes/revoke", admin, { id });
307 expect(rev.status).toBe(303);
308 expect(await (await seeNew(cookie)).text()).toContain("cancelled");
309 });
310
311 test("ten codes fit the first view: one line each", async () => {
312 fresh();
313 for (let i = 0; i < 10; i++) await make({ plan: "small", note: `n${i}` });
314 const text = await (await send("GET", "/settings/billing", admin)).text();
315 const section = text.slice(text.indexOf('id="codes"'));
316 expect(section.match(/data-code="/g)!.length).toBeGreaterThanOrEqual(10);
317 expect(section.match(/<form/g)!.length).toBeLessThanOrEqual(section.match(/data-code="/g)!.length + 1);
318 });
319 });
test/palette-roster.test.ts +1
@@ -163,6 +163,7 @@ test("a site-admin person is offered the whole roster", () => {
163163 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
164164 ["security-controls", "Security controls", "each safeguard, and when it was last tested"],
165165 ["billing", "Billing", "each forge's plan, and grant one by hand"],
166 ["codes", "Gift codes", "make a code that gives a plan for free, and revoke one"],
166167 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],
167168 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
168169 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs