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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2419-invites-part-2c-a-new-forge-spawned-or-f → main 3 commits fork 3 threads · 3 unresolved
5 files
changed +130
−5
README.md +1 −1
Diff not shown — a line 4,879 characters wide (limit 2,000). +1 −1
View filesrc/app.ts +12 −2
| @@ -9,13 +9,14 @@ import type { Database } from "bun:sqlite"; | ||
| 9 | 9 | import type { Decider } from "./crew/decide.ts"; |
| 10 | 10 | import type { VerifyHuman } from "./core/turnstile.ts"; |
| 11 | 11 | import { randomBytes } from "node:crypto"; |
| 12 | import { mkdirSync } from "node:fs"; | |
| 12 | import { existsSync, mkdirSync } from "node:fs"; | |
| 13 | 13 | import { join, resolve } from "node:path"; |
| 14 | 14 | import { migrationFiles } from "./core/schema.ts"; |
| 15 | 15 | import { dropLegacyNewAccountGuards } from "./core/schema.ts"; |
| 16 | 16 | import { EventBus } from "./core/events.ts"; |
| 17 | 17 | import { follow } from "./core/following.ts"; |
| 18 | 18 | import { enqueueForEvent } from "./core/webhooks.ts"; |
| 19 | import { seedSignup } from "./settings/signup.ts"; | |
| 19 | 20 | import { openDb } from "./kernel/db.ts"; |
| 20 | 21 | import { id } from "./kernel/id.ts"; |
| 21 | 22 | import { |
| @@ -31,6 +32,12 @@ import { closeReadersUnder } from "./git/read.ts"; | ||
| 31 | 32 | export interface ForgeConfig { |
| 32 | 33 | /** Everything lives here: index db, bare repos, the internal socket. */ |
| 33 | 34 | 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; | |
| 34 | 41 | /** The disk probe vitals reads; the volume under `dataDir` when unset. Tests give it a reading. */ |
| 35 | 42 | disk?: (dir: string) => { freeBytes: number; totalBytes: number }; |
| 36 | 43 | /** @default 3000 */ |
| @@ -212,7 +219,10 @@ export function createApp(config: ForgeConfig): App { | ||
| 212 | 219 | mkdirSync(reposRoot, { recursive: true }); |
| 213 | 220 | const socketPath = join(dataDir, `forge-${id()}.sock`); |
| 214 | 221 | |
| 215 | const db = openDb(join(dataDir, "forge.db"), migrationFiles); | |
| 222 | const dbPath = join(dataDir, "forge.db"); | |
| 223 | const fresh = !existsSync(dbPath); | |
| 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); | |
| 216 | 226 | // Hand-installed new-account guards become the `guard_new_accounts` |
| 217 | 227 | // setting (default off): drop the legacy triggers once, here, on boot. |
| 218 | 228 | // Idempotent — a forge without them changes nothing. |
src/main.ts +2
| @@ -177,6 +177,8 @@ if (parentProblem !== null) { | ||
| 177 | 177 | |
| 178 | 178 | const app = createApp({ |
| 179 | 179 | dataDir, |
| 180 | // A new forge starts with signup = invite (FORGE_SIGNUP below still wins). | |
| 181 | seedSignup: true, | |
| 180 | 182 | ...(parentUrl === "" ? {} : { parentUrl }), |
| 181 | 183 | port: Number(process.env.PORT ?? 3000), |
| 182 | 184 | ...(process.env.FORGE_HOST === undefined |
src/settings/signup.ts +20 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Who may make an account: `open`, `invite` or `closed`. Stored in |
| 3 | 3 | * `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`). | |
| 5 | 5 | * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask |
| 6 | 6 | * it (`signupClosed` in `actions/users.ts`); `invite` lets in only a person with |
| 7 | 7 | * an open invite link (`core/invites.ts`), spent with the account. |
| @@ -36,6 +36,22 @@ export function getSignup(db: Database): SignupMode { | ||
| 36 | 36 | return parsed.ok ? parsed.value : SIGNUP_DEFAULT; |
| 37 | 37 | } |
| 38 | 38 | |
| 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 | ||
| 39 | 55 | /** Set the mode and keep the change; the same mode again keeps nothing. */ |
| 40 | 56 | export function setSignup(db: Database, value: SignupMode, actorId: string): void { |
| 41 | 57 | const from = getSignup(db); |
| @@ -77,7 +93,9 @@ export function signupChanges(db: Database, limit = 20): SignupChange[] { | ||
| 77 | 93 | .map((r) => ({ |
| 78 | 94 | from: r.from_value, |
| 79 | 95 | 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" : ""), | |
| 81 | 99 | at: r.at, |
| 82 | 100 | })); |
| 83 | 101 | } |
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 | }); |
new above
offline
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.