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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11157-an-admin-makes-gift-codes-in-the-forge-a → main 5 commits fork 1 thread · 1 unresolved
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`; | ||
| 112 | 112 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 113 | 113 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| 114 | 114 | | `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 | | |
| 116 | 116 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | |
| 117 | 117 | | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 397 | |
| 118 | 118 | | `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 | ||
| 6 | 6 | - **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). |
| 7 | 7 | - **Audit:** every grant is a row in `entitlement_change` and a `billing.plan` event. |
| 8 | 8 | - **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 { | ||
| 96 | 96 | /** The request that asked, for the same-origin check on this mutation. */ |
| 97 | 97 | readonly request: Request; |
| 98 | 98 | 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; | |
| 99 | 101 | /** |
| 100 | 102 | * The child's own model key, when the spawner gives it one: an API key, or |
| 101 | 103 | * a person's own setup-token under a budget (their choice), sealed in the child with the |
| @@ -283,7 +285,9 @@ async function make( | ||
| 283 | 285 | const chosen = plan.plan === undefined ? undefined : getPlan(app.db, plan.plan); |
| 284 | 286 | if (plan.plan !== undefined && chosen === undefined) |
| 285 | 287 | 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) { | |
| 287 | 291 | if (owner === undefined) return err({ kind: "refused", reason: "plan", message: `the ${chosen.name} plan is for a signed-in person; sign in first` }); |
| 288 | 292 | if (!owner.isAdmin) return err({ kind: "refused", reason: "plan", message: PAID_PLAN_ADMIN_ONLY(chosen.name) }); |
| 289 | 293 | 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 | ||
| 4 | 4 | export { route as deleteApiV1AdminInvitesId } from "./deleteApiV1AdminInvitesId.ts"; |
| 5 | 5 | export { route as deleteApiV1AgentsUsername } from "./deleteApiV1AgentsUsername.ts"; |
| 6 | 6 | export { route as deleteApiV1BoardsSlug } from "./deleteApiV1BoardsSlug.ts"; |
| 7 | export { route as deleteApiV1CodesId } from "./deleteApiV1CodesId.ts"; | |
| 7 | 8 | export { route as deleteApiV1ConversationFilesId } from "./deleteApiV1ConversationFilesId.ts"; |
| 8 | 9 | export { route as deleteApiV1ConversationQueueId } from "./deleteApiV1ConversationQueueId.ts"; |
| 9 | 10 | export { route as deleteApiV1FeaturesSlug } from "./deleteApiV1FeaturesSlug.ts"; |
| @@ -66,6 +67,7 @@ export { route as getApiV1BoardsSlugPanelsId } from "./getApiV1BoardsSlugPanelsI | ||
| 66 | 67 | export { route as getApiV1BoardsSources } from "./getApiV1BoardsSources.ts"; |
| 67 | 68 | export { route as getApiV1Changelog } from "./getApiV1Changelog.ts"; |
| 68 | 69 | export { route as getApiV1CloudAccounts } from "./getApiV1CloudAccounts.ts"; |
| 70 | export { route as getApiV1Codes } from "./getApiV1Codes.ts"; | |
| 69 | 71 | export { route as getApiV1Contribute } from "./getApiV1Contribute.ts"; |
| 70 | 72 | export { route as getApiV1ConversationFilesId } from "./getApiV1ConversationFilesId.ts"; |
| 71 | 73 | export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts"; |
| @@ -262,6 +264,7 @@ export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdV | ||
| 262 | 264 | export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts"; |
| 263 | 265 | export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts"; |
| 264 | 266 | export { route as postApiV1Builds } from "./postApiV1Builds.ts"; |
| 267 | export { route as postApiV1Codes } from "./postApiV1Codes.ts"; | |
| 265 | 268 | export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts"; |
| 266 | 269 | export { route as postApiV1ConversationFiles } from "./postApiV1ConversationFiles.ts"; |
| 267 | 270 | 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>> = { | ||
| 66 | 66 | returns: "billing", |
| 67 | 67 | credit: "billing", |
| 68 | 68 | spend: "billing", |
| 69 | codes: "billing", | |
| 69 | 70 | usage: "billing", |
| 70 | 71 | forge: "forges", |
| 71 | 72 | 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>> = { | ||
| 109 | 109 | app_deploy: { does: "redeploy or stop an app's production or preview" }, |
| 110 | 110 | stop: { does: "stop a crew run" }, |
| 111 | 111 | 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 }, | |
| 113 | 113 | origins: { does: "sync an origin or replace the internal hosts one may live on", asks: not("action", "list", "repos") }, |
| 114 | 114 | cloud_account: { does: "change a cloud account" }, |
| 115 | 115 | 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"; | ||
| 351 | 351 | import { packRoutes } from "./api/packs.ts"; |
| 352 | 352 | import { forgeViewRoutes } from "./api/forge_view.ts"; |
| 353 | 353 | import { billingRoutes } from "./api/billing.ts"; |
| 354 | import { codesRoutes } from "./api/codes.ts"; | |
| 354 | 355 | import { economicsRoutes } from "./api/economics.ts"; |
| 355 | 356 | import { releasesRoutes } from "./api/releases.ts"; |
| 356 | 357 | import { hookRoutes } from "./api/hooks.ts"; |
| @@ -3587,6 +3588,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3587 | 3588 | forgeViewRoutes(router); |
| 3588 | 3589 | economicsRoutes(router); |
| 3589 | 3590 | billingRoutes(router); |
| 3591 | codesRoutes(router); | |
| 3590 | 3592 | releasesRoutes(router); |
| 3591 | 3593 | |
| 3592 | 3594 | 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 @@ | ||
| 1 | 1 | 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"; | |
| 2 | 4 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 3 | 5 | import { clientRows } from "../../../spawn/clients.ts"; |
| 4 | 6 | import { good, bad, type Tool } from "../shared.ts"; |
| 5 | 7 | |
| 6 | 8 | export const tool: Tool = { |
| 7 | 9 | 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", | |
| 9 | 11 | description: |
| 10 | 12 | "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" } } }, | |
| 12 | 14 | writes: true, |
| 13 | 15 | dryByDefault: true, |
| 14 | readsWithout: ["forge", "plan"], | |
| 16 | readsWithout: ["forge", "plan", "code", "revoke"], | |
| 15 | 17 | async run(ctx, user, args) { |
| 16 | 18 | if (!user.isAdmin) |
| 17 | 19 | 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 | } | |
| 18 | 41 | if (args["forge"] !== undefined || args["plan"] !== undefined) { |
| 19 | 42 | const refuse = (e: string) => (e === "not_found" ? bad("not_found", "Not found.") : bad("invalid", BILLING_WORDS[e as keyof typeof BILLING_WORDS] ?? e)); |
| 20 | 43 | 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 | ||
| 17 | 17 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 18 | 18 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 19 | 19 | 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"; | |
| 21 | 21 | import { economicsPage, economicsSet } from "../web/pages/economics.ts"; |
| 22 | 22 | import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts"; |
| 23 | 23 | import { usagePage } from "../web/pages/usage.ts"; |
| @@ -208,6 +208,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 208 | 208 | router.add("POST", "/settings/economics", economicsSet); |
| 209 | 209 | router.add("GET", "/settings/billing", billingPage); |
| 210 | 210 | router.add("POST", "/settings/billing/grant", billingGrant); |
| 211 | router.add("POST", "/settings/billing/codes", billingMakeCode); | |
| 212 | router.add("POST", "/settings/billing/codes/revoke", billingRevokeCode); | |
| 211 | 213 | router.add("GET", "/settings/flags", flagsPage); |
| 212 | 214 | // Apps absorbed as the forge's own features, and their settings. |
| 213 | 215 | router.add("GET", "/settings/captain", captainPage); |
src/spawn/door.ts +41 −9
| @@ -37,6 +37,8 @@ import { readdirSync, readFileSync } from "node:fs"; | ||
| 37 | 37 | import { readdir, stat } from "node:fs/promises"; |
| 38 | 38 | import { join } from "node:path"; |
| 39 | 39 | 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"; | |
| 40 | 42 | |
| 41 | 43 | export interface Door { |
| 42 | 44 | readonly status: number; |
| @@ -260,11 +262,21 @@ export async function spawnDoor( | ||
| 260 | 262 | // A plan (`settings/plans.ts`) gives the child its size; read now, so an edit governs this spawn. |
| 261 | 263 | let planKey: string | undefined; |
| 262 | 264 | 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 | } | |
| 263 | 275 | if (raw["plan"] !== undefined && raw["plan"] !== null && raw["plan"] !== "") { |
| 264 | 276 | const chosen = typeof raw["plan"] === "string" ? getPlan(db, raw["plan"]) : undefined; |
| 265 | 277 | 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`); | |
| 279 | if (chosen.key !== FREE && user?.isAdmin !== true && giftIn === undefined) return invalid("plan", PAID_PLAN_ADMIN_ONLY(chosen.name)); | |
| 268 | 280 | if (size !== undefined && size !== null && size !== "" && size !== chosen.size) |
| 269 | 281 | return invalid("size", `the ${chosen.name} plan is a ${chosen.size} forge; leave size out or send ${chosen.size}`); |
| 270 | 282 | planKey = chosen.key; |
| @@ -299,6 +311,7 @@ export async function spawnDoor( | ||
| 299 | 311 | plan: planned.value, |
| 300 | 312 | // Who the child's first admin will be: the caller, or whoever claims it. |
| 301 | 313 | firstAdmin: user?.username ?? null, |
| 314 | ...(giftSeen === undefined ? {} : { gift: giftSeen }), | |
| 302 | 315 | ...dryRun(planned.value, source), |
| 303 | 316 | ...(planned.value.budgetUsd === null ? {} : { keyNote: KEY_BUDGET_NOTE }), |
| 304 | 317 | }, |
| @@ -316,14 +329,33 @@ export async function spawnDoor( | ||
| 316 | 329 | }, |
| 317 | 330 | }; |
| 318 | 331 | } |
| 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 | } | |
| 319 | 339 | 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 | } | |
| 327 | 359 | if (!made.ok) return spawnWords(made.error); |
| 328 | 360 | if (anon && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request); |
| 329 | 361 | // 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"; | ||
| 18 | 18 | import { entry as devices } from "./devices.ts"; |
| 19 | 19 | import { entry as bets } from "./bets.ts"; |
| 20 | 20 | import { entry as billing } from "./billing.ts"; |
| 21 | import { entry as codes } from "./codes.ts"; | |
| 21 | 22 | import { entry as boards } from "./boards.ts"; |
| 22 | 23 | import { entry as drift } from "./drift.ts"; |
| 23 | 24 | import { entry as captain } from "./captain.ts"; |
| @@ -135,6 +136,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 135 | 136 | economics, |
| 136 | 137 | money, |
| 137 | 138 | billing, |
| 139 | codes, | |
| 138 | 140 | appTemplates, |
| 139 | 141 | devices, |
| 140 | 142 | flowsAll, |
src/web/gift_cookie.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * A gift code in transit (#11157): the link `/?code=` carries it once, then | |
| 3 | * it rides an HttpOnly cookie for an hour so the next URL, the access log and | |
| 4 | * the history never hold it. The new-forge page reads it from here. | |
| 5 | */ | |
| 6 | ||
| 7 | import { cookieValue } from "../http/context.ts"; | |
| 8 | import { secureFor } from "../http/cookie_secure.ts"; | |
| 9 | ||
| 10 | const NAME = "forge_gift"; | |
| 11 | const SHAPE = /^[A-Za-z0-9-]{1,64}$/; | |
| 12 | ||
| 13 | /** A `303` to `to` that keeps the code in a cookie, not in the address. */ | |
| 14 | export function keepGift(code: string, to: string, url: URL, forgeUrl?: string): Response { | |
| 15 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 16 | const headers = new Headers({ location: to, "cache-control": "no-store" }); | |
| 17 | if (SHAPE.test(code)) headers.append("set-cookie", `${NAME}=${code}; Path=/; Max-Age=3600; SameSite=Lax; HttpOnly${secure}`); | |
| 18 | return new Response(null, { status: 303, headers }); | |
| 19 | } | |
| 20 | ||
| 21 | /** The code a visitor arrived with, or "". */ | |
| 22 | export function keptGift(request: Request): string { | |
| 23 | const v = cookieValue(request, NAME) ?? ""; | |
| 24 | return SHAPE.test(v) ? v : ""; | |
| 25 | } | |
| 26 | ||
| 27 | /** Sent once the forge is made: the code is spent from the browser too. */ | |
| 28 | export function dropGift(url: URL, forgeUrl?: string): string { | |
| 29 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 30 | return `${NAME}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly${secure}`; | |
| 31 | } |
src/web/pages/billing.ts +89 −4
| @@ -6,23 +6,84 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | 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"; | |
| 9 | 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 10 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 11 | 13 | import { SIZES } from "../../spawn/sizes.ts"; |
| 12 | 14 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 13 | 15 | import { field, readForm } from "../form.ts"; |
| 14 | import { html } from "../html.ts"; | |
| 16 | import { html, type Html } from "../html.ts"; | |
| 15 | 17 | import { flashTo, page } from "../layout.ts"; |
| 16 | import { btn } from "../ui.ts"; | |
| 18 | import { btn, copyField, field as formField, selectField } from "../ui.ts"; | |
| 17 | 19 | import { accountSections } from "./origins.ts"; |
| 18 | 20 | |
| 19 | 21 | const PATH = "/settings/billing"; |
| 20 | 22 | const words = (e: BillingError) => (e === "not_found" ? "Not found" : BILLING_WORDS[e]); |
| 21 | 23 | |
| 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 | ||
| 22 | 79 | // GET /settings/billing |
| 23 | 80 | export function billingPage(ctx: Ctx): Response { |
| 24 | 81 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 25 | 82 | 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 { | |
| 26 | 87 | const read = readBilling(ctx.app, ctx.auth.user); |
| 27 | 88 | if (!read.ok) return notFoundPage(ctx); |
| 28 | 89 | const b = read.value; |
| @@ -46,17 +107,41 @@ export function billingPage(ctx: Ctx): Response { | ||
| 46 | 107 | ${rows.length === 0 |
| 47 | 108 | ? html`<p class="muted">No forge has been sent from here.</p>` |
| 48 | 109 | : html`<ul class="list setting-list">${rows}</ul>`} |
| 49 | </section>`; | |
| 110 | </section> | |
| 111 | ${codesSection(ctx, shown, values, error)}`; | |
| 50 | 112 | const response = page({ |
| 51 | 113 | ctx, |
| 52 | 114 | title: "Billing", |
| 53 | 115 | body: html`<div class="page-head"><h1>Billing</h1></div> |
| 54 | 116 | ${settingsLayout(accountSections("billing", { admin: true }), body, "Account settings")}`, |
| 55 | }); | |
| 117 | }, status); | |
| 56 | 118 | 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"); | |
| 57 | 121 | return response; |
| 58 | 122 | } |
| 59 | 123 | |
| 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}`, done.ok ? { message: "Code revoked. Forges already made keep their plan." } : { tone: "danger", message: `${codeWords(done.error)}.` }); | |
| 143 | } | |
| 144 | ||
| 60 | 145 | // POST /settings/billing/grant |
| 61 | 146 | export async function billingGrant(ctx: Ctx): Promise<Response> { |
| 62 | 147 | 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"; | ||
| 15 | 15 | import type { User } from "../../core/users.ts"; |
| 16 | 16 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 17 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 18 | import { giftOf, REDEEM_WORDS } from "../../actions/codes.ts"; | |
| 18 | 19 | import { quotaFull } from "../../spawn/quota.ts"; |
| 19 | 20 | import { childHost, childUrlOf, noRoom, spawnDoor, type Door } from "../../spawn/door.ts"; |
| 20 | 21 | import { |
| @@ -28,6 +29,7 @@ import { | ||
| 28 | 29 | import { DEFAULT_SIZE, SIZES, sizesView } from "../../spawn/sizes.ts"; |
| 29 | 30 | import { DEFAULT_TEMPLATE, TEMPLATES, templateOf } from "../../spawn/templates.ts"; |
| 30 | 31 | import { FOLLOW_LABELS, FOLLOW_WORDS } from "../../spawn/follow_words.ts"; |
| 32 | import { dropGift, keepGift, keptGift } from "../gift_cookie.ts"; | |
| 31 | 33 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 32 | 34 | import { field, readForm, type Form } from "../form.ts"; |
| 33 | 35 | import { html, type Html } from "../html.ts"; |
| @@ -48,6 +50,8 @@ interface Values { | ||
| 48 | 50 | readonly modelEndpoint: string; |
| 49 | 51 | readonly size: string; |
| 50 | 52 | readonly template: string; |
| 53 | /** A gift code (`actions/codes.ts`): it names the plan, so no checkout and no card. */ | |
| 54 | readonly code: string; | |
| 51 | 55 | /** The last post carried a key: it is never sent back, so it is asked for again. */ |
| 52 | 56 | readonly hadKey: boolean; |
| 53 | 57 | } |
| @@ -65,6 +69,7 @@ const EMPTY: Values = { | ||
| 65 | 69 | modelEndpoint: "", |
| 66 | 70 | size: DEFAULT_SIZE, |
| 67 | 71 | template: DEFAULT_TEMPLATE, |
| 72 | code: "", | |
| 68 | 73 | hadKey: false, |
| 69 | 74 | }; |
| 70 | 75 | |
| @@ -344,6 +349,7 @@ function formView( | ||
| 344 | 349 | Never copied: ${LEFT_BEHIND.map(plainWords).join(", ")}. The child mints its own. |
| 345 | 350 | </p> |
| 346 | 351 | </details> |
| 352 | ${codeField(values.code, errors["code"])} | |
| 347 | 353 | <div class="form-actions"> |
| 348 | 354 | <button class="btn" type="submit" name="do" value="plan"> |
| 349 | 355 | See the plan |
| @@ -372,6 +378,7 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record | ||
| 372 | 378 | <ul class="mb"> |
| 373 | 379 | <li>A forge at <b>${address}</b>.</li> |
| 374 | 380 | <li>Whoever opens the link becomes its admin.</li> |
| 381 | ${giftLine(body["gift"])} | |
| 375 | 382 | ${user === undefined |
| 376 | 383 | ? null |
| 377 | 384 | : html`<li> |
| @@ -399,6 +406,16 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record | ||
| 399 | 406 | </section>`; |
| 400 | 407 | } |
| 401 | 408 | |
| 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 | ||
| 402 | 419 | /** The answer to a spawn: the link, once. */ |
| 403 | 420 | /** The claim link, shown once: what to do with it, and a way to copy it. */ |
| 404 | 421 | export function claimLinkBody(link: string, note?: string): Html { |
| @@ -462,10 +479,18 @@ export function newForgePage(ctx: Ctx): Response { | ||
| 462 | 479 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 463 | 480 | const reads = readsFor(ctx, user); |
| 464 | 481 | 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; | |
| 465 | 490 | const response = page({ |
| 466 | 491 | ctx, |
| 467 | 492 | 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), | |
| 469 | 494 | }); |
| 470 | 495 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 471 | 496 | return response; |
| @@ -491,6 +516,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 491 | 516 | modelEndpoint: field(form, "modelEndpoint").trim(), |
| 492 | 517 | size: field(form, "size").trim() || DEFAULT_SIZE, |
| 493 | 518 | template: field(form, "template").trim() || DEFAULT_TEMPLATE, |
| 519 | code: field(form, "code").trim().slice(0, 64), | |
| 494 | 520 | hadKey: field(form, "modelKey").trim() !== "", |
| 495 | 521 | }; |
| 496 | 522 | const key = field(form, "modelKey").trim(); |
| @@ -510,6 +536,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 510 | 536 | // Small is the default: it is not recorded, so the plan stays as it was. |
| 511 | 537 | ...(values.size === DEFAULT_SIZE ? {} : { size: values.size }), |
| 512 | 538 | ...(values.template === DEFAULT_TEMPLATE ? {} : { template: values.template }), |
| 539 | ...(values.code === "" ? {} : { code: values.code }), | |
| 513 | 540 | dryRun: planOnly, |
| 514 | 541 | }); |
| 515 | 542 | if (door.status >= 400) |
| @@ -533,5 +560,13 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 533 | 560 | ); |
| 534 | 561 | // The link is in this body only: never kept by a cache. |
| 535 | 562 | response.headers.set("cache-control", "no-store"); |
| 563 | if (values.code !== "") response.headers.append("set-cookie", dropGift(ctx.url, ctx.app.config.url)); | |
| 536 | 564 | return response; |
| 537 | 565 | } |
| 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 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 12 | import { keepGift } from "../gift_cookie.ts"; | |
| 12 | 13 | import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts"; |
| 13 | 14 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| 14 | 15 | import { storageLimitMessage } from "../../core/storage.ts"; |
| @@ -213,6 +214,9 @@ export function terminalCard(ctx: Ctx): Html { | ||
| 213 | 214 | // the feed (`studioHome`, #599), whatever the mode (#625). |
| 214 | 215 | export async function home(ctx: Ctx): Promise<Response> { |
| 215 | 216 | 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); | |
| 216 | 220 | const res = user === undefined ? await studioDoor(ctx) : await studioHome(ctx, user); |
| 217 | 221 | // RFC 9727 §3: where an agent that knows only the origin finds the API catalog. |
| 218 | 222 | 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/cookie-flags.test.ts +3
| @@ -49,6 +49,8 @@ const UNREACHED: Record<string, { sites: number; why: string }> = { | ||
| 49 | 49 | "src/web/pages/start_claim.ts": { sites: 1, why: "the claim of an agent's start needs a one-time token; same sessionCookie() as the walked doors; covered by test/start.test.ts" }, |
| 50 | 50 | "src/web/pages/signup-passkey.ts": { sites: 2, why: "sign-up by passkey needs an invite; same sessionCookie() as the walked doors" }, |
| 51 | 51 | "src/web/pages/map.ts": { sites: 1, why: "the map's last-looked cookie holds two timestamps, HttpOnly and Lax, and grants nothing; covered by test/roadmap.test.ts (name, HttpOnly, SameSite, no Secure over http)" }, |
| 52 | "src/web/gift_cookie.ts": { sites: 1, why: "the gift code rides an HttpOnly, Lax cookie (Secure over https) and grants nothing by itself; covered by the gift code tests" }, | |
| 53 | "src/web/pages/new-forge.ts": { sites: 1, why: "appends dropGift(), the gift cookie's clearing line from src/web/gift_cookie.ts" }, | |
| 52 | 54 | "src/web/pages/everyday.ts": { sites: 2, why: "the admin/everyday switch needs a paired account; same sessionCookie() as the walked doors" }, |
| 53 | 55 | }; |
| 54 | 56 | |
| @@ -232,6 +234,7 @@ const BUILDERS: Record<string, string> = { | ||
| 232 | 234 | "cookie(": "src/apps/scaffold.ts, the scaffolded app's own `cookie`", |
| 233 | 235 | clear: "src/http/rp.ts, `cookieHeader` with Max-Age 0", |
| 234 | 236 | value: "src/apps/scaffold.ts, `redirectTo`'s cookies, each from `cookie`", |
| 237 | "dropGift(": "src/web/gift_cookie.ts, the gift cookie cleared with Max-Age 0", | |
| 235 | 238 | "o.cookie": "src/web/layout.ts, a page's cookie (the theme literal in src/web/pages/site.ts)", |
| 236 | 239 | }; |
| 237 | 240 |
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 <Mark>"); | |
| 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", () => { | ||
| 163 | 163 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 164 | 164 | ["security-controls", "Security controls", "each safeguard, and when it was last tested"], |
| 165 | 165 | ["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"], | |
| 166 | 167 | ["archived", "Archived repositories", "put away, not gone: open one or restore it"], |
| 167 | 168 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 168 | 169 | ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"], |
Non-blocking: the paid-plan checks are skipped when
giftIn === undefined, but an empty or nullcodeis 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 ongiftSeen !== undefinedso only a code that was actually looked up lends the plan.