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

When signup is closed, no new account is made by any door, except the forge's first #2245

Merged opened this

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

Merged

scout/forge:scout/2166-when-signup-is-closed-no-new-account-is → main 8 commits fork 5 threads · 5 unresolved

  • Merged. scout/2166-when-signup-is-closed-no-new-account-is was merged into main as 36ed61f.

Review

Sign in to review this change.
11 files changed +215 −13
README.md +1 −1

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

View file
src/actions/users.ts +21 −1
@@ -35,6 +35,7 @@ import {
3535 type User,
3636 } from "../core/users.ts";
3737 import { err, ok, type Result } from "../kernel/result.ts";
38 import { getSignup } from "../settings/signup.ts";
3839 import { requireUser } from "./access.ts";
3940
4041 export interface SignUpInput {
@@ -45,7 +46,24 @@ export interface SignUpInput {
4546 humanToken?: string;
4647 }
4748
48 export type SignUpError = CreateUserError | "human_check" | "passkey_required";
49 export type SignUpError =
50 | CreateUserError
51 | "human_check"
52 | "passkey_required"
53 | "signup_closed";
54
55 /** What anyone is told when the forge makes no new accounts. */
56 export const SIGNUP_CLOSED_MESSAGE = "Signup is closed on this forge.";
57
58 /**
59 * Whether no new account may be made now. Read from the store on every call,
60 * so a change takes effect on the next request. `invite` acts as closed until
61 * invitations exist (#1143). The forge's first account is never refused: the
62 * owner cannot be locked out of a fresh forge by a setting.
63 */
64 export function signupClosed(app: App): boolean {

The check itself is right. But the terminal (src/tui/screens/signin.ts) still offers its create form while this returns true. Gate the create mode on it there, and show SIGNUP_CLOSED_MESSAGE the way the page does.

65 return getSignup(app.db) !== "open" && hasAnyUser(app.db);
66 }
4967
5068 /** What anyone is told at a door that still offers a password-only sign-up. */
5169 export const PASSKEY_REQUIRED_MESSAGE =
@@ -84,6 +102,7 @@ export async function signUp(
84102 // when every such account has a passkey (Settings → Passkeys shows who is
85103 // left) and recovery without a password exists (#1618): then delete this
86104 // action's password path, `verifyPassword`'s doors and the password column.
105 if (signupClosed(app)) return err("signup_closed");
87106 if (app.config.passkeyOnlySignup === true) return err("passkey_required");
88107 const secret = app.config.turnstileSecret;
89108 const bootstrap = from.door === "terminal" && !hasAnyUser(app.db);
@@ -131,6 +150,7 @@ export async function signUpWithPasskey(
131150 input: PasskeySignUpInput,
132151 from: { address?: string | undefined } = {},
133152 ): Promise<Result<User, SignUpError | "passkey_taken">> {
153 if (signupClosed(app)) return err("signup_closed");
134154 const secret = app.config.turnstileSecret;
135155 if (secret !== undefined) {
136156 const verify = app.config.verifyHuman ?? turnstileVerifier(secret);
src/http/api.ts +3
@@ -154,6 +154,7 @@ import {
154154 HUMAN_CHECK_MESSAGE,
155155 PASSKEY_REQUIRED_MESSAGE,
156156 signUp,
157 SIGNUP_CLOSED_MESSAGE,
157158 updateProfile,
158159 } from "../actions/users.ts";
159160 import { listEvents } from "../core/events.ts";
@@ -1203,6 +1204,8 @@ export function apiRoutes(router: Router<Handler>): void {
12031204 },
12041205 { address: clientAddress(ctx.request) },
12051206 );
1207 if (!created.ok && created.error === "signup_closed")
1208 return apiError("signup_closed", 403, SIGNUP_CLOSED_MESSAGE);
12061209 if (!created.ok && created.error === "passkey_required")
12071210 return apiError("passkey_required", 422, PASSKEY_REQUIRED_MESSAGE);
12081211 if (!created.ok && created.error === "human_check")
src/http/server.ts +6 −1
@@ -60,6 +60,7 @@ import {
6060 verifyPassword,
6161 } from "../core/users.ts";
6262 import { edgeIdentity } from "./edge.ts";
63 import { signupClosed } from "../actions/users.ts";
6364 import { liveResponse, readyResponse } from "./health.ts";
6465 import { runGit } from "../git/env.ts";
6566 import { noteRefusedInternal } from "../kernel/internal_refused.ts";
@@ -490,7 +491,11 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
490491 const login = edgeIdentity(request, app.config.edgeToken);
491492 if (login !== undefined) {
492493 const existed = getUserByName(app.db, login) !== undefined;
493 const user = await ensureEdgeUser(app.db, login, forgeHost(app.config));
494 // Existing accounts sign in; a new one is made only while signup allows.

Good: an edge login for a new name while closed is refused here and falls through to cookie/token, so it is a guest and nothing is created or audited.

495 const user =
496 !existed && signupClosed(app)
497 ? undefined
498 : await ensureEdgeUser(app.db, login, forgeHost(app.config));
494499 if (user) {
495500 // An account the edge made is audited like one made at /signup.
496501 if (!existed) {
src/settings/signup.ts +2 −1
@@ -2,7 +2,8 @@
22 * Who may make an account: `open`, `invite` or `closed`. Stored in
33 * `forge_setting` under `signup`, and every change is kept in
44 * `forge_setting_change` (who, when, from what to what). Default `open`.
5 * Nothing reads it yet: no sign-in or signup path asks it.
5 * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask
6 * it (`signupClosed` in `actions/users.ts`); `invite` acts as closed until #1143.
67 */
78
89 import type { Database } from "bun:sqlite";
src/tui/screens/common.ts +2
@@ -11,6 +11,7 @@ import type { Ctx, Screen } from "../app/app.ts";
1111 import { binding, type Binding } from "../app/keys.ts";
1212 import type { KeyEvent } from "../term/input.ts";
1313 import type { RepoCounts } from "../../core/repos.ts";
14 import { SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts";
1415 import type { Session } from "../session.ts";
1516 import { G, Screen as Surface, type Rect } from "../term/screen.ts";
1617 import { style } from "../term/style.ts";
@@ -814,6 +815,7 @@ export function slug(path: string): string {
814815
815816 const MESSAGES: Record<string, string> = {
816817 not_found: "not found",
818 signup_closed: SIGNUP_CLOSED_MESSAGE,
817819 human_check: "we could not tell you are a person; sign up on the web page",
818820 passkey_required: "new accounts sign up with a passkey and an email address: open the sign-up page in a browser",
819821 unauthorized: "sign in first",
src/tui/screens/signin.ts +13 −5
@@ -8,7 +8,7 @@
88 * which is the admin — the terminal equivalent of the web's first-run signup.
99 */
1010
11 import { signUp } from "../../actions/users.ts";
11 import { SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
1212 import { hasAnyUser } from "../../core/users.ts";
1313 import type { Ctx, Screen } from "../app/app.ts";
1414 import { binding } from "../app/keys.ts";
@@ -40,6 +40,8 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
4040 // "Is this forge empty?" is a existence question, not a count: `COUNT(*)`
4141 // walks the whole user table on the one screen every session paints.
4242 const first = !hasAnyUser(f.db);
43 // Closed signup offers no create mode at all, as the page offers no form.
44 const closed = signupClosed(f.app);
4345 let mode: "signin" | "create" = first ? "create" : "signin";
4446 let values = ["", "", ""];
4547 let focus = 0;
@@ -50,6 +52,10 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
5052
5153 const submit = (): void => {
5254 if (busy) return;
55 if (closed && mode === "create") {
56 error = SIGNUP_CLOSED_MESSAGE;
57 return;
58 }
5359 busy = true;
5460 error = undefined;
5561 runAsync(ctx, async () => {
@@ -100,21 +106,23 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
100106 keys: [
101107 binding(["tab"], "next field"),
102108 binding(["enter"], mode === "create" ? "create" : "sign in"),
103 binding(["ctrl+t"], first ? "sign in instead" : "create account"),
109 ...(closed ? [] : [binding(["ctrl+t"], first ? "sign in instead" : "create account")]),
104110 binding(["esc"], "back"),
105111 ],
106112 render(area, s) {
107113 const c = ctx.theme.c;
108114 const [head, body] = split(area, "v", [len(2), fill(1)]);
109115 segmented(s, head!, c, {
110 items: ["Sign in", "Create account"],
116 items: closed ? ["Sign in"] : ["Sign in", "Create account"],
111117 active: mode === "create" ? 1 : 0,
112118 });
113119 s.text(
114120 head!.x,
115121 head!.y + 1,
116122 truncate(
117 first
123 closed
124 ? SIGNUP_CLOSED_MESSAGE
125 : first
118126 ? "This forge has no accounts yet — the first one is the admin."
119127 : `Connected from ${f.identity.remoteAddress} with ${f.identity.fingerprint ?? "no key"}`,
120128 head!.w,
@@ -151,7 +159,7 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
151159 ctx.setCursor(cursor);
152160 },
153161 key(e) {
154 if (e.ctrl && e.key === "t") {
162 if (e.ctrl && e.key === "t" && !closed) {
155163 mode = mode === "create" ? "signin" : "create";
156164 values = ["", "", ""];
157165 focus = 0;
src/web/pages/signup-passkey.ts +5 −2
@@ -7,7 +7,7 @@
77 */
88
99 import { newChallenge, verifyRegistration } from "../../auth/webauthn.ts";
10 import { plausibleEmail, signUpWithPasskey, HUMAN_CHECK_MESSAGE } from "../../actions/users.ts";
10 import { plausibleEmail, signUpWithPasskey, signupClosed, HUMAN_CHECK_MESSAGE, SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts";
1111 import { createSession } from "../../core/sessions.ts";
1212 import { getUserByName } from "../../core/users.ts";
1313 import { isSafeName } from "../../git/guard.ts";
@@ -30,6 +30,7 @@ const WORDS: Record<string, string> = {
3030 invalid_email: "That does not look like an email address.",
3131 human_check: HUMAN_CHECK_MESSAGE,
3232 passkey_taken: "That passkey is already registered.",
33 signup_closed: SIGNUP_CLOSED_MESSAGE,
3334 };
3435
3536 /** Whether the name and address are fit to sign up with; else the words. */
@@ -52,6 +53,7 @@ function guard(ctx: Ctx): Response | undefined {
5253 export async function signupOptions(ctx: Ctx): Promise<Response> {
5354 const denied = guard(ctx);
5455 if (denied) return denied;
56 if (signupClosed(ctx.app)) return refuse(403, SIGNUP_CLOSED_MESSAGE);

No test covers this 403 or the signup_closed → 403 mapping in signupPasskey. Add one to the closed/invite loop in test/signup.test.ts.

5557 const data = await boundedBody(ctx.request, STRANGER_MAX);
5658 if (data === "too_large") return refuse(413, "payload too large");
5759 const username = str(data.username).trim();
@@ -71,6 +73,7 @@ export async function signupOptions(ctx: Ctx): Promise<Response> {
7173 export async function signupPasskey(ctx: Ctx): Promise<Response> {
7274 const denied = guard(ctx);
7375 if (denied) return denied;
76 if (signupClosed(ctx.app)) return refuse(403, SIGNUP_CLOSED_MESSAGE);
7477 const data = await boundedBody(ctx.request, STRANGER_MAX);
7578 if (data === "too_large") return refuse(413, "payload too large");
7679 const username = str(data.username).trim();
@@ -91,7 +94,7 @@ export async function signupPasskey(ctx: Ctx): Promise<Response> {
9194 { address: clientAddress(ctx.request) },
9295 );
9396 if (!created.ok)
94 return refuse(created.error === "human_check" ? 422 : 409, WORDS[created.error] ?? "Sign-up did not work. Try again.");
97 return refuse(created.error === "human_check" ? 422 : created.error === "signup_closed" ? 403 : 409, WORDS[created.error] ?? "Sign-up did not work. Try again.");

Nit: a status map beside WORDS ({human_check: 422, signup_closed: 403}, default 409) would read better than the nested ternary.

9598 const session = createSession(ctx.app.db, created.value.id);
9699 return json(
97100 { ok: true, next: safeNext(ctx.url.searchParams.get("next")) ?? "/" },
src/web/pages/site.ts +15 −1
@@ -15,7 +15,7 @@ import { clientAddress } from "../../http/peer.ts";
1515 import { verifyEmail } from "../../actions/verify.ts";
1616 import { STRANGER_MAX } from "../../http/limits.ts";
1717 import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts";
18 import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, signUp } from "../../actions/users.ts";
18 import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
1919 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2020 import { listPins } from "../../core/pins.ts";
2121 import { getReposByIds } from "../../core/repos.ts";
@@ -392,7 +392,20 @@ function siteKey(ctx: Ctx): string | undefined {
392392 return turnstileSecret === undefined ? undefined : turnstileSiteKey;
393393 }
394394
395 /** The whole of `/signup` while the forge makes no new accounts: a sentence, no form. */
396 function signupClosedPage(ctx: Ctx): Response {
397 return page(
398 {
399 ctx,
400 title: "Sign up",
401 body: html`<div class="stack"><h1>Sign up</h1><p>${SIGNUP_CLOSED_MESSAGE}</p><p><a href="/signin">Sign in</a></p></div>`,
402 },
403 403,
404 );
405 }
406
395407 export function signupPage(ctx: Ctx): Response {
408 if (signupClosed(ctx.app)) return signupClosedPage(ctx);
396409 return page({
397410 ctx,
398411 title: "Sign up",
@@ -521,6 +534,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
521534 { address: clientAddress(ctx.request) },
522535 );
523536 if (!created.ok) {
537 if (created.error === "signup_closed") return signupClosedPage(ctx);
524538 const errors: AuthErrors = {
525539 invalid_username: {
526540 username: "Usernames are letters, digits, dots, dashes.",
test/signup.test.ts +126 −1
@@ -13,7 +13,7 @@ import { configProblem } from "../src/kernel/preflight.ts";
1313 import { BOOT_ACTOR, getSignup, setSignup, signupChanges, signupFromEnv } from "../src/settings/signup.ts";
1414 import { createSession } from "../src/core/sessions.ts";
1515 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, type User } from "../src/core/users.ts";
16 import { createUser, getUserByName, type User } from "../src/core/users.ts";
1717 import { serve, type Servers } from "../src/http/server.ts";
1818
1919 describe("signup setting", () => {
@@ -157,3 +157,128 @@ describe("signup setting", () => {
157157 expect(signupChanges(app.db)[0]).toMatchObject({ to: "open", actorName: "FORGE_SIGNUP at boot" });
158158 });
159159 });
160
161 describe("signup closed (#2166)", () => {
162 let dataDir: string;
163 let app: App;
164 let servers: Servers;
165 const EDGE = "edge-secret-0123456789";
166 const post = (body: unknown) =>
167 fetch(`${servers.url}/api/v1/users`, {
168 method: "POST",
169 headers: { "content-type": "application/json" },
170 body: JSON.stringify(body),
171 });
172 const edge = (user: string) =>
173 fetch(`${servers.url}/api/v1/user`, {
174 headers: { "x-plat-user": user, "x-plat-edge-token": EDGE },
175 });
176 let n = 0;
177 const newcomer = () => {
178 n += 1;
179 return { username: `new${n}`, email: `new${n}@example.com`, password: "a-good-passphrase" };
180 };
181
182 beforeAll(async () => {
183 dataDir = await mkdtemp(join(tmpdir(), "forge-signup-closed-"));
184 app = createApp({ dataDir, port: 0, edgeToken: EDGE });
185 servers = await serve(app);
186 });
187 afterAll(async () => {
188 await servers.stop();
189 await rm(dataDir, { recursive: true, force: true });
190 });
191
192 test("the first account is made whatever the setting, by every door", async () => {
193 setSignup(app.db, "closed", "boot");
194 const res = await post({ ...newcomer(), username: "founder", email: "[email protected]" });
195 expect(res.status).toBe(201);
196 expect(getUserByName(app.db, "founder")?.isAdmin).toBe(true);
197 });
198
199 test("a fresh forge's first edge sign-in is made even when closed", async () => {
200 const fresh = await mkdtemp(join(tmpdir(), "forge-signup-edge-"));
201 const a = createApp({ dataDir: fresh, port: 0, edgeToken: EDGE });
202 const s = await serve(a);
203 try {
204 setSignup(a.db, "closed", "boot");
205 const res = await fetch(`${s.url}/api/v1/user`, {
206 headers: { "x-plat-user": "ada", "x-plat-edge-token": EDGE },
207 });
208 expect(res.status).toBe(200);
209 } finally {
210 await s.stop();
211 await rm(fresh, { recursive: true, force: true });
212 }
213 });
214
215 for (const mode of ["closed", "invite"] as const) {
216 test(`${mode}: page is one sentence, API is 403, edge makes no account`, async () => {
217 setSignup(app.db, mode, "boot");
218 const page = await fetch(`${servers.url}/signup`);
219 const text = await page.text();
220 expect(page.status).toBe(403);
221 expect(text).toContain("Signup is closed on this forge.");
222 const main = text.slice(text.indexOf("<main"), text.indexOf("</main>"));
223 expect(main).not.toContain("<form");
224 expect(main).not.toContain("<input");
225
226 const body = newcomer();
227 const res = await post(body);
228 expect(res.status).toBe(403);
229 const err = (await res.json()) as { error: string; message: string };
230 expect(err.error).toBe("signup_closed");
231 expect(err.message.length).toBeGreaterThan(0);
232 expect(getUserByName(app.db, body.username)).toBeUndefined();
233
234 const form = await fetch(`${servers.url}/signup`, {
235 method: "POST",
236 headers: { "content-type": "application/x-www-form-urlencoded" },
237 body: new URLSearchParams({ ...newcomer() }),
238 });
239 expect(form.status).toBe(403);
240
241 const opts = await fetch(`${servers.url}/signup/passkey/options`, {
242 method: "POST",
243 headers: { "content-type": "application/json", origin: servers.url },
244 body: JSON.stringify({ username: `pk${mode}`, email: `pk${mode}@forge.local` }),
245 });
246 expect(opts.status).toBe(403);
247 expect(await opts.text()).toContain("closed");
248 const pk = await fetch(`${servers.url}/signup/passkey`, {
249 method: "POST",
250 headers: { "content-type": "application/json", origin: servers.url },
251 body: JSON.stringify({ username: `pk${mode}`, email: `pk${mode}@forge.local` }),
252 });
253 expect(pk.status).toBe(403);
254 expect(getUserByName(app.db, `pk${mode}`)).toBeUndefined();
255
256 const stranger = `edge${mode}`;
257 expect((await edge(stranger)).status).toBe(401);
258 expect(getUserByName(app.db, stranger)).toBeUndefined();
259 });
260 }
261
262 test("existing accounts still sign in by the edge while closed", async () => {
263 setSignup(app.db, "open", "boot");
264 expect((await edge("returning")).status).toBe(200);
265 setSignup(app.db, "closed", "boot");
266 expect((await edge("returning")).status).toBe(200);
267 expect((await edge("founder")).status).toBe(200);
268 });
269
270 test("open: page has the form and the API makes the account", async () => {
271 setSignup(app.db, "open", "boot");
272 const text = await fetch(`${servers.url}/signup`).then((r) => r.text());
273 expect(text).toContain('name="password"');
274 expect((await post(newcomer())).status).toBe(201);
275 });
276
277 test("switching back to open takes effect on the next request", async () => {
278 setSignup(app.db, "closed", "boot");
279 expect((await post(newcomer())).status).toBe(403);
280 setSignup(app.db, "open", "boot");
281 expect((await post(newcomer())).status).toBe(201);
282 expect((await edge("latecomer")).status).toBe(200);
283 });
284 });
test/tui-screens.test.ts +21
@@ -31,6 +31,7 @@ import { listEvents } from "../src/core/events.ts";
3131 import { getIssue, listComments } from "../src/core/issues.ts";
3232 import { getPull } from "../src/core/pulls.ts";
3333 import { getRepo, type Repo } from "../src/core/repos.ts";
34 import { setSignup } from "../src/settings/signup.ts";
3435 import { getUserSetting } from "../src/core/settings.ts";
3536 import { noteProtection, resetProtection } from "../src/kernel/protection.ts";
3637 import { getUserByName, type User } from "../src/core/users.ts";
@@ -443,6 +444,26 @@ describe("the terminal UI, against a real forge", () => {
443444 h.stop();
444445 });
445446
447 test("a closed forge's terminal offers no Create account and makes none", async () => {
448 setSignup(forge.db, "closed", alice.id);
449 const h = boot(forge, asGuest());
450 try {
451 await h.settle();
452 await h.press("s");
453 expect(h.text()).toContain("Sign in");
454 expect(h.text()).not.toContain("Create account");
455 await h.press("ctrl+t");
456 expect(h.text()).not.toContain("Create account");
457 expect(h.text()).toContain("Username");
458 const refused = await signUp(forge, { username: "carol", email: "[email protected]", password: "a-long-passphrase" }, { address: "127.0.0.1" });
459 expect(refused).toMatchObject({ ok: false, error: "signup_closed" });
460 } finally {
461 h.stop();
462 setSignup(forge.db, "open", alice.id);
463 }
464 expect(getUserByName(forge.db, "carol")).toBeFalsy();
465 });
466
446467 test("signing in links the presenting key and unlocks the private repo", async () => {
447468 const h = boot(forge, asGuest());
448469 // The key this session presents becomes alice's on success, and the

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs