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

Merged opened this

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

Merged

scout/forge:scout/2165-a-forge-setting-signup-open-invite-or-cl → main 6 commits fork 5 threads · 5 unresolved

  • Merged. scout/2165-a-forge-setting-signup-open-invite-or-cl was merged into main as 39d97d4.

Review

Sign in to review this change.
11 files changed +397 −4
README.md +2
@@ -1132,6 +1132,8 @@ Every limit the owner can hit is declared in `src/settings/limits.ts` with its d
11321132
11331133 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.
11341134
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
11351137 ## Choosing among variants
11361138
11371139 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";
2323 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2424 import { changeLimit, listLimits } from "../actions/limits.ts";
2525 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
26 import { changeSignup, readSignup } from "../actions/signup.ts";
2627 import {
2728 accountSignIns,
2829 endAccountSession,
@@ -1511,16 +1512,25 @@ export function apiRoutes(router: Router<Handler>): void {
15111512 const denied = accountAdmin(ctx, "ro");
15121513 if (denied) return denied;
15131514 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();
15151517 });
15161518
15171519 router.add("PATCH", "/api/v1/admin/settings", async (ctx) => {
15181520 const denied = accountAdmin(ctx, "admin");
15191521 if (denied) return denied;
15201522 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 }
15211531 const quotas = input.quotas;
15221532 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}}");
15241534 const changed = changeQuotas(ctx.app, ctx.auth.user, quotas as Record<string, unknown>);
15251535 if (changed.ok) return json({ quotas: changed.value });
15261536 if (changed.error === "not_found") return notFound();
src/http/mcp.ts +35
@@ -23,6 +23,8 @@ import { listAccess } from "../actions/access_log.ts";
2323 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2424 import { changeLimit, listLimits } from "../actions/limits.ts";
2525 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
26 import { changeSignup, readSignup } from "../actions/signup.ts";
27 import { SIGNUP_MODES } from "../settings/signup.ts";
2628 import { QUOTA_KEYS } from "../settings/quotas.ts";
2729 import { LIMIT_KEYS } from "../settings/limits.ts";
2830 import { relationOf, takeUpdates } from "../actions/forks.ts";
@@ -2178,6 +2180,39 @@ const TOOLS: readonly Tool[] = [
21782180 });
21792181 },
21802182 },
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 },
21812216 {
21822217 name: "boards",
21832218 description:
src/http/server.ts +2 −1
@@ -17,7 +17,7 @@ import { contributePage } from "../web/pages/contribute.ts";
1717 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
1818 import { domainsPage } from "../web/pages/domains.ts";
1919 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";
2121 import { boardsPage } from "../web/pages/boards.ts";
2222 import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts";
2323 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
@@ -763,6 +763,7 @@ export function buildRouter(): Router<Handler> {
763763 router.add("GET", "/admin/limits", limitsPage);
764764 router.add("POST", "/admin/limits", limitsSet);
765765 router.add("POST", "/admin/quotas", quotasSet);
766 router.add("POST", "/admin/signup", signupSet);
766767 router.add("GET", "/settings/backups", backupsPage);
767768 router.add("POST", "/settings/backups/now", backupsNow);
768769 router.add("POST", "/settings/backups/drill", backupsDrill);
src/kernel/preflight.ts +3
@@ -9,6 +9,7 @@
99 * `finally`, whatever happened.
1010 */
1111
12 import { signupFromEnv } from "../settings/signup.ts";
1213 import { Database } from "bun:sqlite";
1314 import {
1415 copyFileSync,
@@ -72,6 +73,8 @@ export function configProblem(
7273 return `${hosted !== "" ? "FORGE_APP_URL" : "FORGE_URL"}: "${url}" is not a URL`;
7374 }
7475 }
76 const signup = signupFromEnv(env.FORGE_SIGNUP);
77 if (!signup.ok) return signup.error;
7578 return null;
7679 }
7780
src/main.ts +5
@@ -4,6 +4,7 @@
44 * environment-only — a handful of variables, all with sane defaults.
55 */
66
7 import { BOOT_ACTOR, setSignup, signupFromEnv } from "./settings/signup.ts";
78 import { startMemorySampler } from "./boards/memory.ts";
89 import { startSampler } from "./boards/sampler.ts";
910 import { sweepStanding } from "./crew/standing.ts";
@@ -242,6 +243,10 @@ const app = createApp({
242243 });
243244
244245 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 (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 closed on the page isn't surprised when the next deploy undoes it.

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.

245250 const flagTimer = setInterval(() => flushFlagUses(app.db), 10_000);
246251 await reconcile(app);
247252 // 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 ?1 keeps every query in the repository bound and costs nothing.

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 @@
77
88 import { changeLimit, listLimits } from "../../actions/limits.ts";
99 import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
10 import { changeSignup, readSignup } from "../../actions/signup.ts";
11 import { SIGNUP_MODES } from "../../settings/signup.ts";
1012 import { QUOTA_KEYS } from "../../settings/quotas.ts";
1113 import type { Ctx } from "../../http/context.ts";
1214 import { notFoundPage, settingsLayout } from "../chrome.ts";
@@ -112,7 +114,41 @@ export function limitsPage(ctx: Ctx): Response {
112114 pad: false,
113115 },
114116 );
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(
116152 html`<div class="card-body">
117153 <p class="muted small">
118154 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> {
211247 : { tone: "danger", message: "A quota is a positive whole number, or empty for no limit." },
212248 );
213249 }
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", () => {
213213 "contribute",
214214 "limits",
215215 "quotas",
216 "signup",
216217 "boards",
217218 "board_write",
218219 "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): read.changes.length >= 0 is always true. It also reads the wrong field: in the GET response changes is the quota history, and the signup history is signupChanges (src/http/api.ts). Assert read.signupChanges has length 1 with {from:"open", to:"closed", actorName:"alice"}, so the GET door's audit listing is actually covered.

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): [403, 404] accepts either answer, so this test can't tell a 404 from a regression to 403. The code answers 404, the same as quotas (not_found for a non-admin). Pin it: expect(res.status).toBe(404). If you keep 404 rather than the 403 the issue mentions, say so in the pull.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs