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

Invites, part 2c: a new forge (spawned or fresh) starts with signup = invite; existing forges keep their value #2573

Merged opened this

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

Merged

scout/forge:scout/2419-invites-part-2c-a-new-forge-spawned-or-f → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/2419-invites-part-2c-a-new-forge-spawned-or-f was merged into main as b953396.

Review

Sign in to review this change.
5 files changed +130 −5
README.md +1 −1

Diff not shown — a line 4,879 characters wide (limit 2,000). +1 −1

View file
src/app.ts +12 −2
@@ -9,13 +9,14 @@ import type { Database } from "bun:sqlite";
99 import type { Decider } from "./crew/decide.ts";
1010 import type { VerifyHuman } from "./core/turnstile.ts";
1111 import { randomBytes } from "node:crypto";
12 import { mkdirSync } from "node:fs";
12 import { existsSync, mkdirSync } from "node:fs";
1313 import { join, resolve } from "node:path";
1414 import { migrationFiles } from "./core/schema.ts";
1515 import { dropLegacyNewAccountGuards } from "./core/schema.ts";
1616 import { EventBus } from "./core/events.ts";
1717 import { follow } from "./core/following.ts";
1818 import { enqueueForEvent } from "./core/webhooks.ts";
19 import { seedSignup } from "./settings/signup.ts";
1920 import { openDb } from "./kernel/db.ts";
2021 import { id } from "./kernel/id.ts";
2122 import {
@@ -31,6 +32,12 @@ import { closeReadersUnder } from "./git/read.ts";
3132 export interface ForgeConfig {
3233 /** Everything lives here: index db, bare repos, the internal socket. */
3334 dataDir: string;
35 /**
36 * A database made by this call starts with signup = invite (`seedSignup`);
37 * one that already exists is left as it is. Off by default so a test's
38 * forge reads the setting's `open`; `main.ts` turns it on.
39 */
40 seedSignup?: boolean;
3441 /** The disk probe vitals reads; the volume under `dataDir` when unset. Tests give it a reading. */
3542 disk?: (dir: string) => { freeBytes: number; totalBytes: number };
3643 /** @default 3000 */
@@ -212,7 +219,10 @@ export function createApp(config: ForgeConfig): App {
212219 mkdirSync(reposRoot, { recursive: true });
213220 const socketPath = join(dataDir, `forge-${id()}.sock`);
214221
215 const db = openDb(join(dataDir, "forge.db"), migrationFiles);
222 const dbPath = join(dataDir, "forge.db");
223 const fresh = !existsSync(dbPath);

fresh is computed before openDb, but the seed runs after it. Move the seed into the same transaction as the migrations to remove the crash gap.

224 const db = openDb(dbPath, migrationFiles);

Freshness from file existence is the source of the crash gap; deriving it from the DB (no migrations applied yet) and seeding in the same transaction removes it.

225 if (config.seedSignup === true) seedSignup(db, fresh);
216226 // Hand-installed new-account guards become the `guard_new_accounts`
217227 // setting (default off): drop the legacy triggers once, here, on boot.
218228 // Idempotent — a forge without them changes nothing.
src/main.ts +2
@@ -177,6 +177,8 @@ if (parentProblem !== null) {
177177
178178 const app = createApp({
179179 dataDir,
180 // A new forge starts with signup = invite (FORGE_SIGNUP below still wins).
181 seedSignup: true,
180182 ...(parentUrl === "" ? {} : { parentUrl }),
181183 port: Number(process.env.PORT ?? 3000),
182184 ...(process.env.FORGE_HOST === undefined
src/settings/signup.ts +20 −2
@@ -1,7 +1,7 @@
11 /**
22 * Who may make an account: `open`, `invite` or `closed`. Stored in
33 * `forge_setting` under `signup`, and every change is kept in
4 * `forge_setting_change` (who, when, from what to what). Default `open`.
4 * `forge_setting_change` (who, when, from what to what). Default `open` when nothing is stored; a new forge stores `invite` (`seedSignup`).
55 * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask
66 * it (`signupClosed` in `actions/users.ts`); `invite` lets in only a person with
77 * an open invite link (`core/invites.ts`), spent with the account.
@@ -36,6 +36,22 @@ export function getSignup(db: Database): SignupMode {
3636 return parsed.ok ? parsed.value : SIGNUP_DEFAULT;
3737 }
3838
39 /** The actor kept for the value a new forge starts with. */
40 export const FRESH_ACTOR = "fresh";
41
42 /**
43 * What a new forge starts with: `invite`, stored so it is the forge's own
44 * value from then on. Only a forge that made its database just now is new
45 * (`fresh`); one that stored a value keeps it, and one that never stored any
46 * keeps the `open` it has always read as. A spawned child is a fresh data
47 * directory, so it starts here too.
48 */
49 export function seedSignup(db: Database, fresh: boolean): void {
50 if (!fresh) return;
51 const stored = db.query("SELECT 1 FROM forge_setting WHERE key = ?1").get(STORE_KEY);
52 if (stored === null) writeSetting(db, STORE_KEY, "invite", SIGNUP_DEFAULT, "invite", FRESH_ACTOR);
53 }
54
3955 /** Set the mode and keep the change; the same mode again keeps nothing. */
4056 export function setSignup(db: Database, value: SignupMode, actorId: string): void {
4157 const from = getSignup(db);
@@ -77,7 +93,9 @@ export function signupChanges(db: Database, limit = 20): SignupChange[] {
7793 .map((r) => ({
7894 from: r.from_value,
7995 to: r.to_value,
80 actorName: r.username ?? (r.actor_id === BOOT_ACTOR ? "FORGE_SIGNUP at boot" : ""),
96 actorName:
97 r.username ??
98 (r.actor_id === BOOT_ACTOR ? "FORGE_SIGNUP at boot" : r.actor_id === FRESH_ACTOR ? "new forge" : ""),
8199 at: r.at,
82100 }));
83101 }
test/signup-fresh.test.ts added +95
@@ -0,0 +1,95 @@
1 /**
2 * A new forge starts with signup = invite; a forge with a stored value keeps
3 * it; the first admin is untouched (first-admin.test.ts).
4 */
5
6 import { describe, expect, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { Database } from "bun:sqlite";
11 import { createApp } from "../src/app.ts";
12 import { getSignup, seedSignup, setSignup, signupChanges } from "../src/settings/signup.ts";
13
14 const app = () => createApp({ dataDir: mkdtempSync(join(tmpdir(), "fresh-")) });
15
16 describe("seedSignup", () => {
17 test("a fresh forge stores invite and keeps the change", () => {
18 const a = app();
19 expect(getSignup(a.db)).toBe("open");
20 seedSignup(a.db, true);
21 expect(getSignup(a.db)).toBe("invite");
22 expect(signupChanges(a.db)[0]?.actorName).toBe("new forge");
23 a.close();
24 });
25
26 test("a forge that stored open keeps it", () => {
27 const a = app();
28 setSignup(a.db, "closed", "boot");
29 setSignup(a.db, "open", "boot");
30 seedSignup(a.db, true);
31 expect(getSignup(a.db)).toBe("open");
32 a.close();
33 });
34
35 test("an existing forge with nothing stored is left alone", () => {
36 const a = app();
37 seedSignup(a.db, false);
38 expect(getSignup(a.db)).toBe("open");
39 expect(signupChanges(a.db)).toHaveLength(0);
40 a.close();
41 });
42 });
43
44 describe("createApp with seedSignup", () => {
45 test("a data directory with no forge.db starts with invite; one with a forge.db keeps its value", () => {
46 const dir = mkdtempSync(join(tmpdir(), "fresh-"));
47 const first = createApp({ dataDir: dir, seedSignup: true });
48 expect(getSignup(first.db)).toBe("invite");
49 setSignup(first.db, "open", "boot");
50 first.close();
51 const again = createApp({ dataDir: dir, seedSignup: true });
52 expect(getSignup(again.db)).toBe("open");
53 again.close();
54 });
55
56 test("a forge.db that never stored a value stays open", () => {
57 const dir = mkdtempSync(join(tmpdir(), "fresh-"));
58 createApp({ dataDir: dir }).close();
59 const again = createApp({ dataDir: dir, seedSignup: true });
60 expect(getSignup(again.db)).toBe("open");
61 again.close();
62 });
63
64 test("without the option nothing is seeded", () => {
65 const a = app();
66 expect(getSignup(a.db)).toBe("open");
67 a.close();
68 });
69 });
70
71 describe("main.ts", () => {
72 test("booting on an empty data directory stores invite", async () => {
73 const dir = mkdtempSync(join(tmpdir(), "fresh-"));
74 const proc = Bun.spawn(["bun", "run", join(import.meta.dir, "../src/main.ts")], {
75 env: { ...process.env, FORGE_DATA: dir, FORGE_APP_DATA: "", FORGE_SIGNUP: "", PORT: "0", FORGE_PORT: "0" },
76 stdout: "ignore",
77 stderr: "ignore",
78 });
79 try {
80 let value: string | undefined;
81 for (let i = 0; i < 100 && value === undefined; i++) {
82 await Bun.sleep(100);
83 try {
84 const db = new Database(join(dir, "forge.db"), { readonly: true });
85 value = db.query<{ value: string }, []>("SELECT value FROM forge_setting WHERE key = 'signup'").get()?.value;
86 db.close();
87 } catch {}
88 }

Empty catch hides why the boot failed; capture stderr and include it in the failure.

89 expect(value).toBe("invite");
90 } finally {
91 proc.kill();
92 await proc.exited;
93 }
94 });
95 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs