Public: anyone can read this; the crew can change it.
A forge setting `signup` (open, invite or closed) that an admin changes live, kept in the audit #2169
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2165-a-forge-setting-signup-open-invite-or-cl → main 6 commits fork 5 threads · 5 unresolved
README.md +2
| @@ -1132,6 +1132,8 @@ Every limit the owner can hit is declared in `src/settings/limits.ts` with its d | ||
| 1132 | 1132 | |
| 1133 | 1133 | Per-account quotas (`src/settings/quotas.ts`) — how many `repositories` and `apps` one account may own — are set the same way: **Account quotas** on `/admin/limits`, `GET`/`PATCH /api/v1/admin/settings` (`{"quotas": {"repositories": 5, "apps": null}}`) or the `quotas` MCP tool (dry by default, admin scope, never an agent). A value is a positive whole number, or empty/`null` for no limit, which is the default; anything else is a 422. Every change is kept with who made it and listed beside the form. The `apps` limit is enforced (`src/actions/app_limit.ts`): with one set, an account owning that many apps is refused one more, unless the actor is a forge admin; with none set nothing is asked. The check and the insert are separate statements, so two concurrent creates can pass by one. The refusal reads "This account is at its limit: 3 of 3 apps. Remove one to make room." and each door answers it its own way: the API (`POST /api/v1/apps` and `POST /api/v1/repos/:owner/:name/app`) a 403 `{"error": "app_limit", "message", "limit", "count"}`; the new-app page and the app settings form a 422 with the sentence on the form; make-it-yours the same sentence as an invalid error, before the fork, so no repository is left behind. The `repositories` limit is not enforced yet (#2093): no create path reads it. |
| 1134 | 1134 | |
| 1135 | The forge setting `signup` (`src/settings/signup.ts`) is `open`, `invite` or `closed`, and `open` is the default. A forge admin who is a person changes it live — **Signup** on `/admin/limits`, `PATCH /api/v1/admin/settings` (`{"signup":"closed"}`) or the `signup` MCP tool (dry by default, admin scope, never an agent) — with no restart; any other value is a 422. `FORGE_SIGNUP` sets it at boot, and a wrong value stops the boot with a plain message; while it stays set, every restart or deploy writes it again over a change made on the page (kept as a change by `boot`), so unset it to let the page decide. Every change is kept with who made it and when, and listed beside the form. Nothing reads it yet: no sign-in or signup path asks it. | |
| 1136 | ||
| 1135 | 1137 | ## Choosing among variants |
| 1136 | 1138 | |
| 1137 | 1139 | A tell with `variants: n` starts n builders whose pulls share a variant group. |
src/actions/signup.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * The signup setting (`settings/signup.ts`), for a forge admin who is a | |
| 3 | * person. Everyone else, an agent included, is told `not_found`. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { App } from "../app.ts"; | |
| 7 | import { | |
| 8 | getSignup, | |
| 9 | parseSignup, | |
| 10 | setSignup, | |
| 11 | signupChanges, | |
| 12 | type SignupChange, | |
| 13 | type SignupMode, | |
| 14 | } from "../settings/signup.ts"; | |
| 15 | import type { User } from "../core/users.ts"; | |
| 16 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 17 | ||
| 18 | export type SignupError = "not_found" | "invalid_signup"; | |
| 19 | ||
| 20 | function admin(actor: User | undefined): Result<User, "not_found"> { | |
| 21 | return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found"); | |
| 22 | } | |
| 23 | ||
| 24 | export function readSignup( | |
| 25 | app: App, | |
| 26 | actor: User | undefined, | |
| 27 | ): Result<{ signup: SignupMode; changes: SignupChange[] }, "not_found"> { | |
| 28 | const who = admin(actor); | |
| 29 | return who.ok ? ok({ signup: getSignup(app.db), changes: signupChanges(app.db) }) : who; | |
| 30 | } | |
| 31 | ||
| 32 | export function changeSignup( | |
| 33 | app: App, | |
| 34 | actor: User | undefined, | |
| 35 | raw: unknown, | |
| 36 | ): Result<SignupMode, SignupError> { | |
| 37 | const who = admin(actor); | |
| 38 | if (!who.ok) return who; | |
| 39 | const mode = parseSignup(raw); | |
| 40 | if (!mode.ok) return mode; | |
| 41 | setSignup(app.db, mode.value, who.value.id); | |
| 42 | return ok(mode.value); | |
| 43 | } |
src/http/api.ts +12 −2
| @@ -23,6 +23,7 @@ import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; | ||
| 23 | 23 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 24 | 24 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 25 | 25 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; |
| 26 | import { changeSignup, readSignup } from "../actions/signup.ts"; | |
| 26 | 27 | import { |
| 27 | 28 | accountSignIns, |
| 28 | 29 | endAccountSession, |
| @@ -1511,16 +1512,25 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1511 | 1512 | const denied = accountAdmin(ctx, "ro"); |
| 1512 | 1513 | if (denied) return denied; |
| 1513 | 1514 | const read = readQuotas(ctx.app, ctx.auth.user); |
| 1514 | return read.ok ? json(read.value) : notFound(); | |
| 1515 | const signup = readSignup(ctx.app, ctx.auth.user); | |
| 1516 | return read.ok && signup.ok ? json({ ...read.value, ...signup.value, signupChanges: signup.value.changes, changes: read.value.changes }) : notFound(); | |
| 1515 | 1517 | }); |
| 1516 | 1518 | |
| 1517 | 1519 | router.add("PATCH", "/api/v1/admin/settings", async (ctx) => { |
| 1518 | 1520 | const denied = accountAdmin(ctx, "admin"); |
| 1519 | 1521 | if (denied) return denied; |
| 1520 | 1522 | const input = await body(ctx.request); |
| 1523 | if (input.signup !== undefined) { | |
| 1524 | if (input.quotas !== undefined) | |
| 1525 | return apiError("invalid_body", 422, "send signup or quotas, not both"); | |
| 1526 | const set = changeSignup(ctx.app, ctx.auth.user, input.signup); | |
| 1527 | if (set.ok) return json({ signup: set.value }); | |
| 1528 | if (set.error === "not_found") return notFound(); | |
| 1529 | return apiError(set.error, 422, "signup is open, invite or closed"); | |
| 1530 | } | |
| 1521 | 1531 | const quotas = input.quotas; |
| 1522 | 1532 | if (typeof quotas !== "object" || quotas === null || Array.isArray(quotas)) |
| 1523 | return apiError("invalid_body", 422, "send {\"quotas\": {\"repositories\": 5, \"apps\": null}}"); | |
| 1533 | return apiError("invalid_body", 422, "send {\"signup\": \"closed\"} or {\"quotas\": {\"repositories\": 5, \"apps\": null}}"); | |
| 1524 | 1534 | const changed = changeQuotas(ctx.app, ctx.auth.user, quotas as Record<string, unknown>); |
| 1525 | 1535 | if (changed.ok) return json({ quotas: changed.value }); |
| 1526 | 1536 | if (changed.error === "not_found") return notFound(); |
src/http/mcp.ts +35
| @@ -23,6 +23,8 @@ import { listAccess } from "../actions/access_log.ts"; | ||
| 23 | 23 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 24 | 24 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 25 | 25 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; |
| 26 | import { changeSignup, readSignup } from "../actions/signup.ts"; | |
| 27 | import { SIGNUP_MODES } from "../settings/signup.ts"; | |
| 26 | 28 | import { QUOTA_KEYS } from "../settings/quotas.ts"; |
| 27 | 29 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 28 | 30 | import { relationOf, takeUpdates } from "../actions/forks.ts"; |
| @@ -2178,6 +2180,39 @@ const TOOLS: readonly Tool[] = [ | ||
| 2178 | 2180 | }); |
| 2179 | 2181 | }, |
| 2180 | 2182 | }, |
| 2183 | { | |
| 2184 | name: "signup", | |
| 2185 | description: | |
| 2186 | "Who may sign up: open, invite or closed (default open). Nothing reads it yet. Without `mode`, the setting and the recent changes. With `mode`, the change it would make; with dryRun false, made, by a forge admin with an admin-scope token, never an agent. The same as /admin/limits (Signup) and PATCH /api/v1/admin/settings.", | |
| 2187 | inputSchema: { | |
| 2188 | type: "object", | |
| 2189 | properties: { | |
| 2190 | mode: { type: "string", enum: [...SIGNUP_MODES] }, | |
| 2191 | dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." }, | |
| 2192 | }, | |
| 2193 | }, | |
| 2194 | writes: true, | |
| 2195 | dryByDefault: true, | |
| 2196 | run(ctx, user, args) { | |
| 2197 | const read = readSignup(ctx.app, user); | |
| 2198 | if (!read.ok) return bad("not_found", "no such tool here"); | |
| 2199 | if (args["mode"] === undefined) return good(read.value); | |
| 2200 | if (!(SIGNUP_MODES as readonly unknown[]).includes(args["mode"])) | |
| 2201 | return bad("invalid_signup", "signup is open, invite or closed"); | |
| 2202 | if (args["dryRun"] !== false) | |
| 2203 | return good({ | |
| 2204 | says: `Would set signup from ${read.value.signup} to ${String(args["mode"])}. Send dryRun: false to change it.`, | |
| 2205 | }); | |
| 2206 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 2207 | return bad("forbidden", "changing the forge's signup needs an admin-scope token"); | |
| 2208 | const changed = changeSignup(ctx.app, user, args["mode"]); | |
| 2209 | if (!changed.ok) return bad(changed.error, "signup is open, invite or closed"); | |
| 2210 | return good({ | |
| 2211 | says: `signup: ${read.value.signup} → ${changed.value}.`, | |
| 2212 | signup: changed.value, | |
| 2213 | }); | |
| 2214 | }, | |
| 2215 | }, | |
| 2181 | 2216 | { |
| 2182 | 2217 | name: "boards", |
| 2183 | 2218 | description: |
src/http/server.ts +2 −1
| @@ -17,7 +17,7 @@ import { contributePage } from "../web/pages/contribute.ts"; | ||
| 17 | 17 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 18 | 18 | import { domainsPage } from "../web/pages/domains.ts"; |
| 19 | 19 | import { resiliencePage } from "../web/pages/resilience.ts"; |
| 20 | import { limitsPage, limitsSet, quotasSet } from "../web/pages/admin-limits.ts"; | |
| 20 | import { limitsPage, limitsSet, quotasSet, signupSet } from "../web/pages/admin-limits.ts"; | |
| 21 | 21 | import { boardsPage } from "../web/pages/boards.ts"; |
| 22 | 22 | import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts"; |
| 23 | 23 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| @@ -763,6 +763,7 @@ export function buildRouter(): Router<Handler> { | ||
| 763 | 763 | router.add("GET", "/admin/limits", limitsPage); |
| 764 | 764 | router.add("POST", "/admin/limits", limitsSet); |
| 765 | 765 | router.add("POST", "/admin/quotas", quotasSet); |
| 766 | router.add("POST", "/admin/signup", signupSet); | |
| 766 | 767 | router.add("GET", "/settings/backups", backupsPage); |
| 767 | 768 | router.add("POST", "/settings/backups/now", backupsNow); |
| 768 | 769 | router.add("POST", "/settings/backups/drill", backupsDrill); |
src/kernel/preflight.ts +3
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * `finally`, whatever happened. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { signupFromEnv } from "../settings/signup.ts"; | |
| 12 | 13 | import { Database } from "bun:sqlite"; |
| 13 | 14 | import { |
| 14 | 15 | copyFileSync, |
| @@ -72,6 +73,8 @@ export function configProblem( | ||
| 72 | 73 | return `${hosted !== "" ? "FORGE_APP_URL" : "FORGE_URL"}: "${url}" is not a URL`; |
| 73 | 74 | } |
| 74 | 75 | } |
| 76 | const signup = signupFromEnv(env.FORGE_SIGNUP); | |
| 77 | if (!signup.ok) return signup.error; | |
| 75 | 78 | return null; |
| 76 | 79 | } |
| 77 | 80 |
src/main.ts +5
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * environment-only — a handful of variables, all with sane defaults. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { BOOT_ACTOR, setSignup, signupFromEnv } from "./settings/signup.ts"; | |
| 7 | 8 | import { startMemorySampler } from "./boards/memory.ts"; |
| 8 | 9 | import { startSampler } from "./boards/sampler.ts"; |
| 9 | 10 | import { sweepStanding } from "./crew/standing.ts"; |
| @@ -242,6 +243,10 @@ const app = createApp({ | ||
| 242 | 243 | }); |
| 243 | 244 | |
| 244 | 245 | loadFlags(app.db); |
| 246 | // FORGE_SIGNUP (checked in configProblem) sets who may sign up; kept like any change. | |
| 247 | const bootSignup = signupFromEnv(process.env.FORGE_SIGNUP); | |
| 248 | if (bootSignup.ok && bootSignup.value !== null) | |
| 249 | setSignup(app.db, bootSignup.value, BOOT_ACTOR); | |
Low (documented in the README): while FORGE_SIGNUP is set, every boot overwrites a change made on the page and writes a new audit row. That's fine for now. When something starts reading signup, the page should say the env var is pinning it, so the owner is not surprised. |
||
| 245 | 250 | const flagTimer = setInterval(() => flushFlagUses(app.db), 10_000); |
| 246 | 251 | await reconcile(app); |
| 247 | 252 | // Issues linked under a pillar's umbrella before pillars had labels. |
src/settings/signup.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** | |
| 2 | * Who may make an account: `open`, `invite` or `closed`. Stored in | |
| 3 | * `forge_setting` under `signup`, and every change is kept in | |
| 4 | * `forge_setting_change` (who, when, from what to what). Default `open`. | |
| 5 | * Nothing reads it yet: no sign-in or signup path asks it. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 10 | import { writeSetting } from "./limits.ts"; | |
| 11 | ||
| 12 | export const SIGNUP_MODES = ["open", "invite", "closed"] as const; | |
| 13 | export type SignupMode = (typeof SIGNUP_MODES)[number]; | |
| 14 | export const SIGNUP_DEFAULT: SignupMode = "open"; | |
| 15 | ||
| 16 | /** The actor kept for a change made by `FORGE_SIGNUP` at boot. */ | |
| 17 | export const BOOT_ACTOR = "boot"; | |
| 18 | ||
| 19 | const STORE_KEY = "signup"; | |
| 20 | ||
| 21 | /** A mode from untrusted input: exactly one of the three, nothing else. */ | |
| 22 | export function parseSignup(raw: unknown): Result<SignupMode, "invalid_signup"> { | |
| 23 | return typeof raw === "string" && (SIGNUP_MODES as readonly string[]).includes(raw) | |
| 24 | ? ok(raw as SignupMode) | |
| 25 | : err("invalid_signup"); | |
| 26 | } | |
| 27 | ||
| 28 | export function getSignup(db: Database): SignupMode { | |
| 29 | const row = db | |
| 30 | .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1") | |
| 31 | .get(STORE_KEY); | |
| 32 | if (row === null) return SIGNUP_DEFAULT; | |
| 33 | const parsed = parseSignup(row.value); | |
| 34 | return parsed.ok ? parsed.value : SIGNUP_DEFAULT; | |
| 35 | } | |
| 36 | ||
| 37 | /** Set the mode and keep the change; the same mode again keeps nothing. */ | |
| 38 | export function setSignup(db: Database, value: SignupMode, actorId: string): void { | |
| 39 | const from = getSignup(db); | |
| 40 | if (from === value) return; | |
| 41 | writeSetting(db, STORE_KEY, value, from, value, actorId); | |
| 42 | } | |
| 43 | ||
| 44 | /** `FORGE_SIGNUP` from the environment: unset is no opinion, a bad value is a message. */ | |
| 45 | export function signupFromEnv(raw: string | undefined): Result<SignupMode | null, string> { | |
| 46 | const s = (raw ?? "").trim(); | |
| 47 | if (s === "") return ok(null); | |
| 48 | const parsed = parseSignup(s); | |
| 49 | return parsed.ok | |
| 50 | ? parsed | |
| 51 | : err(`FORGE_SIGNUP: open, invite or closed, not "${s.slice(0, 20)}"`); | |
| 52 | } | |
| 53 | ||
| 54 | export interface SignupChange { | |
| 55 | readonly from: string; | |
| 56 | readonly to: string; | |
| 57 | readonly actorName: string; | |
| 58 | readonly at: string; | |
| 59 | } | |
| 60 | ||
| 61 | /** The most recent changes, newest first. */ | |
| 62 | export function signupChanges(db: Database, limit = 20): SignupChange[] { | |
| 63 | return db | |
| 64 | .query< | |
| 65 | { from_value: string; to_value: string; username: string | null; actor_id: string; at: string }, | |
| 66 | [] | |
| 67 | >( | |
| 68 | `SELECT c.from_value, c.to_value, u.username, c.actor_id, c.at | |
| 69 | FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id | |
| 70 | WHERE c.key = 'signup' | |
| 71 | ORDER BY c.at DESC, c.rowid DESC | |
| 72 | LIMIT ${Math.trunc(limit)}`, | |
Low: the limit is interpolated into the SQL. It is never user text, but binding it as |
||
| 73 | ) | |
| 74 | .all() | |
| 75 | .map((r) => ({ | |
| 76 | from: r.from_value, | |
| 77 | to: r.to_value, | |
| 78 | actorName: r.username ?? (r.actor_id === BOOT_ACTOR ? "FORGE_SIGNUP at boot" : ""), | |
| 79 | at: r.at, | |
| 80 | })); | |
| 81 | } |
src/web/pages/admin-limits.ts +54 −1
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| 9 | 9 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; |
| 10 | import { changeSignup, readSignup } from "../../actions/signup.ts"; | |
| 11 | import { SIGNUP_MODES } from "../../settings/signup.ts"; | |
| 10 | 12 | import { QUOTA_KEYS } from "../../settings/quotas.ts"; |
| 11 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 12 | 14 | import { notFoundPage, settingsLayout } from "../chrome.ts"; |
| @@ -112,7 +114,41 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 112 | 114 | pad: false, |
| 113 | 115 | }, |
| 114 | 116 | ); |
| 115 | const body = html`${quotaCard}${card( | |
| 117 | const signup = readSignup(ctx.app, ctx.auth.user); | |
| 118 | const signupHistory = (signup.ok ? signup.value.changes : []).map((c) => | |
| 119 | row({ | |
| 120 | lead: icon("history"), | |
| 121 | title: html`<span class="mono">${c.from}</span> → | |
| 122 | <span class="mono">${c.to}</span>`, | |
| 123 | meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`, | |
| 124 | }), | |
| 125 | ); | |
| 126 | const signupCard = card( | |
| 127 | html`<div class="card-body"> | |
| 128 | <p class="muted small"> | |
| 129 | Who may sign up: open, invite or closed. Nothing reads this yet. | |
| 130 | </p> | |
| 131 | <form class="row-actions" method="post" action="/admin/signup"> | |
| 132 | <label class="small">signup | |
| 133 | <select class="input mono" name="signup"> | |
| 134 | ${SIGNUP_MODES.map( | |
| 135 | (m) => html`<option value="${m}" ${signup.ok && signup.value.signup === m ? "selected" : ""}>${m}</option>`, | |
| 136 | )} | |
| 137 | </select></label> | |
| 138 | ${btn("Save", { size: "sm", variant: "primary", label: "Save signup" })} | |
| 139 | </form> | |
| 140 | </div> | |
| 141 | ${when( | |
| 142 | signupHistory.length > 0, | |
| 143 | () => html`<ul class="list">${signupHistory}</ul>`, | |
| 144 | )}`, | |
| 145 | { | |
| 146 | head: html`${icon("activity")} | |
| 147 | <h2>Signup</h2>`, | |
| 148 | pad: false, | |
| 149 | }, | |
| 150 | ); | |
| 151 | const body = html`${signupCard}${quotaCard}${card( | |
| 116 | 152 | html`<div class="card-body"> |
| 117 | 153 | <p class="muted small"> |
| 118 | 154 | Every limit you can hit, and why it is there. A change governs |
| @@ -211,3 +247,20 @@ export async function quotasSet(ctx: Ctx): Promise<Response> { | ||
| 211 | 247 | : { tone: "danger", message: "A quota is a positive whole number, or empty for no limit." }, |
| 212 | 248 | ); |
| 213 | 249 | } |
| 250 | ||
| 251 | // POST /admin/signup | |
| 252 | export async function signupSet(ctx: Ctx): Promise<Response> { | |
| 253 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") | |
| 254 | return notFoundPage(ctx); | |
| 255 | const form = await readForm(ctx, "admin"); | |
| 256 | if (form instanceof Response) return form; | |
| 257 | const changed = changeSignup(ctx.app, ctx.auth.user, field(form, "signup")); | |
| 258 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); | |
| 259 | return flashTo( | |
| 260 | ctx, | |
| 261 | "/admin/limits", | |
| 262 | changed.ok | |
| 263 | ? { message: "Signup saved." } | |
| 264 | : { tone: "danger", message: "Signup is open, invite or closed." }, | |
| 265 | ); | |
| 266 | } |
test/mcp.test.ts +1
| @@ -213,6 +213,7 @@ describe("the forge over MCP", () => { | ||
| 213 | 213 | "contribute", |
| 214 | 214 | "limits", |
| 215 | 215 | "quotas", |
| 216 | "signup", | |
| 216 | 217 | "boards", |
| 217 | 218 | "board_write", |
| 218 | 219 | "board_delete", |
test/signup.test.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
| 2 | * The `signup` forge setting (#2165): open by default, an admin changes it | |
| 3 | * live from the API, the MCP tool and the page, a bad value is a 422, a | |
| 4 | * non-admin is refused, FORGE_SIGNUP is checked at boot, every change is kept. | |
| 5 | */ | |
| 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 { configProblem } from "../src/kernel/preflight.ts"; | |
| 13 | import { BOOT_ACTOR, getSignup, setSignup, signupChanges, signupFromEnv } from "../src/settings/signup.ts"; | |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | ||
| 19 | describe("signup setting", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice: User; | |
| 24 | let bob: User; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-signup-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | servers = await serve(app); | |
| 30 | for (const username of ["alice", "bob"]) { | |
| 31 | const made = await createUser(app.db, { | |
| 32 | trusted: true, | |
| 33 | username, | |
| 34 | email: `${username}@forge.local`, | |
| 35 | password: "a-good-passphrase", | |
| 36 | adminIfFirst: username === "alice", | |
| 37 | }); | |
| 38 | if (!made.ok) throw new Error(made.error); | |
| 39 | if (username === "alice") alice = made.value; | |
| 40 | else bob = made.value; | |
| 41 | } | |
| 42 | }); | |
| 43 | afterAll(async () => { | |
| 44 | await servers.stop(); | |
| 45 | await rm(dataDir, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | const api = (token: string, method: string, body?: unknown) => | |
| 49 | fetch(`${servers.url}/api/v1/admin/settings`, { | |
| 50 | method, | |
| 51 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 52 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 53 | }); | |
| 54 | const mcp = async (token: string, args: Record<string, unknown>) => { | |
| 55 | const res = await fetch(`${servers.url}/mcp`, { | |
| 56 | method: "POST", | |
| 57 | headers: { | |
| 58 | authorization: `Bearer ${token}`, | |
| 59 | "content-type": "application/json", | |
| 60 | accept: "application/json, text/event-stream", | |
| 61 | }, | |
| 62 | body: JSON.stringify({ | |
| 63 | jsonrpc: "2.0", | |
| 64 | id: 1, | |
| 65 | method: "tools/call", | |
| 66 | params: { name: "signup", arguments: args }, | |
| 67 | }), | |
| 68 | }); | |
| 69 | return res.text(); | |
| 70 | }; | |
| 71 | ||
| 72 | test("defaults to open; the API sets it live and the audit row names who", async () => { | |
| 73 | const t = createToken(app.db, alice.id, "t", "admin").token; | |
| 74 | expect(getSignup(app.db)).toBe("open"); | |
| 75 | const set = await api(t, "PATCH", { signup: "closed" }); | |
| 76 | expect(set.status).toBe(200); | |
| 77 | expect(getSignup(app.db)).toBe("closed"); | |
| 78 | const changes = signupChanges(app.db); | |
| 79 | expect(changes).toHaveLength(1); | |
| 80 | expect(changes[0]).toMatchObject({ from: "open", to: "closed", actorName: "alice" }); | |
| 81 | const read = (await (await api(t, "GET")).json()) as { | |
| 82 | signup: string; | |
outdated
High (test): |
||
| 83 | signupChanges: { from: string; to: string; actorName: string }[]; | |
| 84 | }; | |
| 85 | expect(read.signup).toBe("closed"); | |
| 86 | expect(read.signupChanges).toHaveLength(1); | |
| 87 | expect(read.signupChanges[0]).toMatchObject({ from: "open", to: "closed", actorName: "alice" }); | |
| 88 | // The same mode again keeps nothing. | |
| 89 | await api(t, "PATCH", { signup: "closed" }); | |
| 90 | expect(signupChanges(app.db)).toHaveLength(1); | |
| 91 | }); | |
| 92 | ||
| 93 | test("any other value is a 422 and changes nothing", async () => { | |
| 94 | const t = createToken(app.db, alice.id, "t", "admin").token; | |
| 95 | for (const bad of ["Closed", "", "nope", 1, null, ["open"]]) { | |
| 96 | const res = await api(t, "PATCH", { signup: bad }); | |
| 97 | expect(res.status).toBe(422); | |
| 98 | } | |
| 99 | expect(getSignup(app.db)).toBe("closed"); | |
| 100 | }); | |
outdated
Medium (test): |
||
| 101 | ||
| 102 | test("a non-admin is refused", async () => { | |
| 103 | const t = createToken(app.db, bob.id, "t", "admin").token; | |
| 104 | const res = await api(t, "PATCH", { signup: "open" }); | |
| 105 | expect(res.status).toBe(404); | |
| 106 | expect(getSignup(app.db)).toBe("closed"); | |
| 107 | }); | |
| 108 | ||
| 109 | test("the MCP tool is dry by default and sets it with dryRun false", async () => { | |
| 110 | const t = createToken(app.db, alice.id, "t", "admin").token; | |
| 111 | await mcp(t, { mode: "invite" }); | |
| 112 | expect(getSignup(app.db)).toBe("closed"); | |
| 113 | await mcp(t, { mode: "invite", dryRun: false }); | |
| 114 | expect(getSignup(app.db)).toBe("invite"); | |
| 115 | await mcp(t, { mode: "bogus", dryRun: false }); | |
| 116 | expect(getSignup(app.db)).toBe("invite"); | |
| 117 | const b = createToken(app.db, bob.id, "t", "admin").token; | |
| 118 | await mcp(b, { mode: "open", dryRun: false }); | |
| 119 | expect(getSignup(app.db)).toBe("invite"); | |
| 120 | }); | |
| 121 | ||
| 122 | test("the admin page sets it, refuses a cross-origin post and a non-admin, and lists the change", async () => { | |
| 123 | const aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 124 | const bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 125 | const post = (cookie: string, signup: string, origin: string) => | |
| 126 | fetch(`${servers.url}/admin/signup`, { | |
| 127 | method: "POST", | |
| 128 | redirect: "manual", | |
| 129 | headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin }, | |
| 130 | body: new URLSearchParams({ signup }), | |
| 131 | }); | |
| 132 | // Before: invite (set through MCP above). | |
| 133 | expect((await post(aliceCookie, "closed", "https://evil.example")).status).toBe(403); | |
| 134 | expect(getSignup(app.db)).toBe("invite"); | |
| 135 | expect((await post(bobCookie, "closed", servers.url)).status).toBe(404); | |
| 136 | expect(getSignup(app.db)).toBe("invite"); | |
| 137 | await post(aliceCookie, "nope", servers.url); | |
| 138 | expect(getSignup(app.db)).toBe("invite"); | |
| 139 | app.db.run("DELETE FROM forge_setting WHERE key = 'signup'"); | |
| 140 | const res = await post(aliceCookie, "invite", servers.url); | |
| 141 | expect(res.status).toBe(303); | |
| 142 | expect(getSignup(app.db)).toBe("invite"); | |
| 143 | const text = await (await fetch(`${servers.url}/admin/limits`, { headers: { cookie: aliceCookie } })).text(); | |
| 144 | expect(text).toMatch(/<option value="invite"\s+selected/); | |
| 145 | expect(text).toContain("alice"); | |
| 146 | }); | |
| 147 | ||
| 148 | test("FORGE_SIGNUP: unset is no opinion, a wrong value stops the boot, boot is kept", () => { | |
| 149 | expect(signupFromEnv(undefined)).toEqual({ ok: true, value: null }); | |
| 150 | expect(signupFromEnv(" invite ")).toEqual({ ok: true, value: "invite" }); | |
| 151 | const bad = signupFromEnv("wide-open"); | |
| 152 | expect(bad.ok).toBe(false); | |
| 153 | expect(configProblem({ FORGE_SIGNUP: "wide-open" }, "/nowhere")).toContain("FORGE_SIGNUP"); | |
| 154 | expect(configProblem({ FORGE_SIGNUP: "closed" }, "/nowhere")).toBeNull(); | |
| 155 | setSignup(app.db, "open", BOOT_ACTOR); | |
| 156 | expect(getSignup(app.db)).toBe("open"); | |
| 157 | expect(signupChanges(app.db)[0]).toMatchObject({ to: "open", actorName: "FORGE_SIGNUP at boot" }); | |
| 158 | }); | |
| 159 | }); |
Low (behaviour, no change required): with FORGE_SIGNUP set, every restart writes it again over an admin's live change. One audit row says so, but nothing else does. That is a fair reading of "sets it at boot". Add a sentence to the README so an owner who set
closedon the page isn't surprised when the next deploy undoes it.