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

Request access: a stranger can ask for an account, and the admin allows it with one tap #10979

Closed opened this in answer to
Closed without merging

torque/forge:torque/10928-request-access-a-stranger-can-ask-for-an → main 2 commits fork 4 threads · 4 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
26 files changed +692 −33
docs/MCP.md +1 −1
@@ -193,7 +193,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
193193 | `sessions` | `list`, `end`, `end_idle`, `signins`, `shells` | Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner; action end + id signs a browser out | 385 |
194194 | `sever` | — | Unlink a spawned forge from its parent (owner of its `forge` repo only) | 214 |
195195 | `share` | — | Share a feed item: no `to` tells the app's crew and owners, else followers; budgeted | 258 |
196 | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 281 |
196 | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 291 |
197197 | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 |
198198 | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 |
199199 | `spawn` | — | Spawn a child forge | 824 |
docs/design/audit.md +2
@@ -130,6 +130,8 @@ Machine routes are not pages and have no entry: `/audit.csv`,
130130 - `/login` — `http/server.ts` (`signinAlias`) · the address people type to sign in · it is `/signin`, keeping a same-origin `next` · none (308, no page of its own).
131131 - `/sign-in` — `http/server.ts` (`signinAlias`) · the same, hyphenated · it is `/signin` · none (308, no page of its own).
132132 - `/signup` — `pages/site.ts` · make an account · `/signup` passkey flow (`signup-passkey.ts`) · colours 3 (file).
133 - `/signup/requested` — `pages/site.ts` · tells a stranger the request is sent · `/signin` · colours 3 (file).
134 - `/signup/welcome` — `pages/site.ts` · tells a person their request was allowed · `/` · colours 3 (file).
133135 - `/verify` — `pages/site.ts` · confirm an email address · none · colours 3 (file).
134136 - `/recover` — `pages/passkeys.ts` · get back in without a password · `/settings/passkeys` · none seen.
135137 - `/settings` — `pages/account.ts` · my account · the rest of settings · colour 1.
docs/features/request-access.md added +11
@@ -0,0 +1,11 @@
1 # Request access
2
3 One setting, **New accounts**, on `/admin/limits`: `open` (anyone, at once), `request` (anyone may ask; an admin allows), `invite` (invite links only), `closed` (nobody, invite links included). Invite links work in every mode except `closed`. A forge that stored `closed` when this shipped now stores `request` (a migration, kept in the setting's history); a new forge still starts at `invite`.
4
5 **A stranger** opens `/signup` and gives a name, a password or passkey, and an optional "What do you want to build?". The account is made *waiting*: it is held suspended, so no session, token or key works. They land on "Your request is in…". Signing in later with the right password says where the request stands (in, or declined, politely) and makes no session. After Allow, the first sign-in says "You're in." once.
6
7 **By API:** `POST /api/v1/users` with `username`, `password`, optional `note` and `from` (which forge or agent asks) answers `202 {"state":"waiting"}`.
8
9 **The admin** sees "Waiting" (name, note, when, from where) with Allow and Decline on home ("Needs you") and at the top of `/settings/people`, and gets a line on the phone through the notify URL when someone asks. Decline keeps the record. A second tap on an answered request changes nothing.
10
11 Not done here: new-account quotas for `open`, and the agent start call landing as a request (#10912 is not in the code yet).
src/actions/accounts.ts +19
@@ -12,6 +12,7 @@
1212
1313 import type { Database } from "bun:sqlite";
1414 import type { App } from "../app.ts";
15 import { waitingRequests } from "../core/account_requests.ts";
1516 import { getAppByRepo } from "../core/apps.ts";
1617 import { listTeams, retireOrg, soleOwnedOrgs } from "../core/orgs.ts";
1718 import { appsRuntime } from "../apps/runtime.ts";
@@ -314,6 +315,22 @@ export function verifyAccount(
314315 return ok(user);
315316 }
316317
318 /**
319 * One line per person waiting for an account (#10928), for an admin's phone.
320 * The line names no one's note; Allow and Decline are on home and on the
321 * people page, one tap from the link.
322 */
323 export function requestLines(
324 db: Database,
325 user: User,
326 ): { id: string; line: string; items: readonly string[] }[] {
327 if (!user.isAdmin || user.kind !== "user") return [];
328 return waitingRequests(db).map((r) => {
329 const id = `request:${r.username}`;
330 return { id, line: `${r.username} asked for an account: Allow or Decline`, items: [id] };
331 });
332 }
333
317334 /** An account counts as new for this long after it is made. */
318335 export const NEW_FOR_MS = 7 * 24 * 3_600_000;
319336
@@ -329,9 +346,11 @@ export function newAccountLines(
329346 ): { id: string; line: string; items: readonly string[] }[] {
330347 if (!user.isAdmin || user.kind !== "user") return [];
331348 const since = new Date(now - NEW_FOR_MS).toISOString();
349 const asking = new Set(waitingRequests(db).map((r) => r.username));
332350 return readAccounts(db)
333351 .filter(
334352 (a) =>
353 !asking.has(a.username) &&
335354 a.kind === "user" &&
336355 !a.isAdmin &&
337356 a.removedAt === null &&
src/actions/requests.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * Requests for an account (#10928): an admin who is a person sees who is
3 * waiting, and allows or declines each. Anyone else is told `not_found`,
4 * like every admin surface. Allow lifts the hold on the account at once;
5 * decline keeps the record. A second tap on a settled request changes
6 * nothing and says so (`settled`).
7 */
8
9 import type { App } from "../app.ts";
10 import {
11 decideRequest,
12 requestOf,
13 waitingRequests,
14 type AccountRequest,
15 } from "../core/account_requests.ts";
16 import { getUserByName, passwordMatches, type User } from "../core/users.ts";
17 import { err, ok, type Result } from "../kernel/result.ts";
18
19 export type RequestError = "not_found" | "settled";
20
21 function admin(actor: User | undefined): Result<User, "not_found"> {
22 return actor?.isAdmin === true && actor.kind === "user" ? ok(actor) : err("not_found");
23 }
24
25 export function listWaiting(app: App, actor: User | undefined): Result<AccountRequest[], "not_found"> {
26 const who = admin(actor);
27 return who.ok ? ok(waitingRequests(app.db)) : who;
28 }
29
30 function decide(
31 app: App,
32 actor: User | undefined,
33 username: string,
34 to: "allowed" | "declined",
35 ): Result<AccountRequest, RequestError> {
36 const who = admin(actor);
37 if (!who.ok) return who;
38 const user = getUserByName(app.db, username);
39 if (user === undefined || requestOf(app.db, user.id) === undefined) return err("not_found");
40 if (!decideRequest(app.db, user.id, to, who.value.id, new Date().toISOString())) return err("settled");
41 app.bus.append({
42 kind: to === "allowed" ? "account.allowed" : "account.declined",
43 actorId: who.value.id,
44 payload: { username: user.username },
45 });
46 return ok(requestOf(app.db, user.id)!);
47 }
48
49 export const allowRequest = (app: App, actor: User | undefined, username: string) =>
50 decide(app, actor, username, "allowed");
51
52 export const declineRequest = (app: App, actor: User | undefined, username: string) =>
53 decide(app, actor, username, "declined");
54
55 /**
56 * The answer to a person who signed in with the right password to an account
57 * that has not been allowed (waiting or declined): its request, so the page
58 * can say where it stands. No session is made, so it can do nothing else.
59 * Asked only after the throttled password check refused, so a guess costs
60 * what it always did.
61 */
62 export async function pendingFor(
63 app: App,
64 username: string,
65 password: string,
66 ): Promise<AccountRequest | undefined> {
67 const row = app.db
68 .query<{ id: string; password_hash: string }, [string]>(
69 "SELECT id, password_hash FROM user WHERE username = ?1 COLLATE NOCASE AND kind = 'user' AND is_agent = 0",
70 )
71 .get(username);
72 if (row === null || row.password_hash === "") return undefined;
73 const asked = requestOf(app.db, row.id);
74 if (asked === undefined || asked.state === "allowed") return undefined;
75 const valid = await passwordMatches(password, row.password_hash);

HIGH: this verify is unthrottled and runs even when the failure was the sign-in window refusing. It is a guessing oracle that bypasses the throttle, and it costs a second argon2 per request. Call it only when the failure was a plain bad password, and count it in the same windows. The early return for a missing user is also faster than a verify, so it leaks which accounts exist.

76 return valid ? asked : undefined;
77 }
src/actions/users.ts +26 −1
@@ -43,6 +43,7 @@ import {
4343 } from "../core/users.ts";
4444 import { err, ok, type Result } from "../kernel/result.ts";
4545 import { requireUser } from "./access.ts";
46 import { requestOf } from "../core/account_requests.ts";
4647 import type { SignupSlot } from "./signup-window.ts";
4748
4849 export interface SignUpInput {
@@ -52,6 +53,8 @@ export interface SignUpInput {
5253 password: string;
5354 /** The Turnstile token the browser's widget gave; read only when a secret is set. */
5455 humanToken?: string;
56 /** While signup is `request`: what they want to build (optional, bounded). */
57 note?: string;
5558 }
5659
5760 export type SignUpError =
@@ -120,6 +123,8 @@ export async function signUp(
120123 /** The token of the invite link the person came by (`core/invites.ts`). */
121124 invite?: string | undefined;
122125 address?: string | undefined;
126 /** Where an ask came from when it is not a person at the page: "api", "agent", a forge. */
127 via?: string | undefined;
123128 },
124129 ): Promise<Result<User, SignUpError>> {
125130 // A username and a password is a way in that stays, beside the passkey
@@ -148,6 +153,8 @@ export async function signUp(
148153 origin: "signup",
149154 slot: from.slot,
150155 invite: from.invite,
156 note: input.note,
157 via: from.via ?? "web",
151158 });
152159 if (!created.ok) return created;
153160 app.bus.append({
@@ -158,6 +165,7 @@ export async function signUp(
158165 ...(from.address === undefined ? {} : { address: from.address }),
159166 },
160167 });
168 announceRequest(app, created.value.id);
161169 auditInvite(app, created.value.id);
162170 // The address is proven by the link it is sent; until then nothing it
163171 // does may create or cost.
@@ -172,6 +180,7 @@ export interface PasskeySignUpInput {
172180 passkey: Credential;
173181 passkeyName: string;
174182 humanToken?: string;
183 note?: string;
175184 }
176185
177186 /**
@@ -184,7 +193,7 @@ export interface PasskeySignUpInput {
184193 export async function signUpWithPasskey(
185194 app: App,
186195 input: PasskeySignUpInput,
187 from: { slot: SignupSlot; invite?: string | undefined; address?: string | undefined },
196 from: { slot: SignupSlot; invite?: string | undefined; address?: string | undefined; via?: string | undefined },
188197 ): Promise<Result<User, SignUpError | "passkey_taken">> {
189198 if (signupClosed(app, from.invite)) return err("signup_closed");
190199 const secret = app.config.turnstileSecret;
@@ -202,6 +211,8 @@ export async function signUpWithPasskey(
202211 origin: "passkey",
203212 slot: from.slot,
204213 invite: from.invite,
214 note: input.note,
215 via: from.via ?? "web",
205216 });
206217 if (!created.ok) return created;
207218 const kept = addPasskey(app.db, created.value.id, input.passkeyName, input.passkey);
@@ -217,12 +228,26 @@ export async function signUpWithPasskey(
217228 ...(from.address === undefined ? {} : { address: from.address }),
218229 },
219230 });
231 announceRequest(app, created.value.id);
220232 auditInvite(app, created.value.id);
221233 // As on the password path: no address given, no link to send.
222234 if (email !== "") await welcome(app, created.value);
223235 return created;
224236 }
225237
238 /** Whether this account is a request that has not been allowed (waiting or declined). */
239 export function isWaiting(app: App, userId: string): boolean {
240 const asked = requestOf(app.db, userId);
241 return asked !== undefined && asked.state !== "allowed";
242 }
243
244 /** A request is an event, so the admin's home and phone hear of it at once. The note stays in the table. */
245 function announceRequest(app: App, userId: string): void {
246 const asked = requestOf(app.db, userId);
247 if (asked?.state !== "waiting") return;
248 app.bus.append({ kind: "account.requested", actorId: userId, payload: { via: asked.via } });
249 }
250
226251 /** Using an invite link is an event: the invite's id and who it let in, never its token. */
227252 function auditInvite(app: App, userId: string): void {
228253 const inviteId = inviteSpentBy(app.db, userId);
src/contract/openapi/routes/postApiV1Users.ts +3 −2
@@ -4,11 +4,12 @@ export const route: Route = {
44 method: "post",
55 path: "/api/v1/users",
66 summary:
7 "Sign up: answers 201 with the new account { id, username, displayName, isAdmin, commitEmail }, never the admin; `email` is optional; `invite` is an invite link's token, `turnstile_token` the human check when the forge asks for one; refused from another origin with 403 forbidden",
7 "Sign up: answers 201 with the new account { id, username, displayName, isAdmin, commitEmail }, never the admin, or, while the forge takes requests (signup `request`), 202 { state: waiting }: the account waits until an admin allows it, and `note` (what you want to build) and `from` (which forge or agent asks) ride along; `email` is optional; `invite` is an invite link's token, `turnstile_token` the human check when the forge asks for one; refused from another origin with 403 forbidden",
88 auth: false,
99 role: "anonymous",
1010 status: 201,
11 body: { username: "string", email: "string", password: "string", invite: "string", turnstile_token: "string" },
11 alsoStatus: [202],
12 body: { username: "string", email: "string", password: "string", note: "string", from: "string", invite: "string", turnstile_token: "string" },
1213 refusals: {
1314 rate_limited: "429, too many sign-ups; retry-after says when",
1415 too_large: "413, the body is over the limit for a stranger's",
src/core/account_requests.ts added +112
@@ -0,0 +1,112 @@
1 /**
2 * Requests for an account (`signup` = `request`, #10928). A request is a row
3 * beside a user who is held suspended while it waits or is declined; Allow
4 * lifts the suspension, Decline keeps the record. Plain data in and out: no
5 * authz here (`actions/requests.ts` decides who may).
6 */
7
8 import type { Database } from "bun:sqlite";
9
10 export type RequestState = "waiting" | "allowed" | "declined";
11
12 export interface AccountRequest {
13 readonly userId: string;
14 readonly username: string;
15 readonly state: RequestState;
16 readonly note: string;
17 /** Where it came from: "web", "api", "agent" or a forge's name. */
18 readonly via: string;
19 readonly createdAt: string;
20 readonly decidedAt: string | null;
21 readonly greetedAt: string | null;
22 }
23
24 export const NOTE_MAX = 500;
25 export const VIA_MAX = 80;
26
27 /** A note from a stranger: control characters gone, trimmed, bounded. */
28 export function cleanNote(raw: unknown, max = NOTE_MAX): string {
29 if (typeof raw !== "string") return "";
30 // eslint-disable-next-line no-control-regex
31 return raw.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").trim().slice(0, max);
32 }
33
34 /** Record a request for a user just made; call inside the transaction that makes it. */
35 export function openRequest(db: Database, userId: string, note: string, via: string, at: string): void {
36 db.query(
37 "INSERT INTO account_request (user_id, state, note, via, created_at) VALUES (?1, 'waiting', ?2, ?3, ?4)",
38 ).run(userId, cleanNote(note), cleanNote(via, VIA_MAX), at);
39 db.query("UPDATE user SET suspended_at = ?2 WHERE id = ?1").run(userId, at);
40 }
41
42 interface Row {
43 user_id: string;
44 username: string;
45 state: RequestState;
46 note: string;
47 via: string;
48 created_at: string;
49 decided_at: string | null;
50 greeted_at: string | null;
51 }
52
53 const SELECT = `SELECT r.user_id, u.username, r.state, r.note, r.via, r.created_at, r.decided_at, r.greeted_at
54 FROM account_request r JOIN user u ON u.id = r.user_id`;
55
56 const toRequest = (r: Row): AccountRequest => ({
57 userId: r.user_id,
58 username: r.username,
59 state: r.state,
60 note: r.note,
61 via: r.via,
62 createdAt: r.created_at,
63 decidedAt: r.decided_at,
64 greetedAt: r.greeted_at,
65 });
66
67 export function requestOf(db: Database, userId: string): AccountRequest | undefined {
68 const row = db.query<Row, [string]>(`${SELECT} WHERE r.user_id = ?1`).get(userId);
69 return row === null ? undefined : toRequest(row);
70 }
71
72 /** Those waiting, oldest first, at most `limit`. */
73 export function waitingRequests(db: Database, limit = 100): AccountRequest[] {
74 return db
75 .query<Row, [number]>(`${SELECT} WHERE r.state = 'waiting' ORDER BY r.created_at, r.user_id LIMIT ?1`)
76 .all(Math.trunc(limit))
77 .map(toRequest);
78 }
79
80 export function waitingCount(db: Database): number {
81 return db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM account_request WHERE state = 'waiting'").get()?.n ?? 0;
82 }
83
84 /**
85 * Settle a waiting request. Allow lifts the suspension; decline keeps it.
86 * Only a waiting request changes: a second tap is a no-op (false).
87 */
88 export function decideRequest(
89 db: Database,
90 userId: string,
91 to: "allowed" | "declined",
92 by: string,
93 at: string,
94 ): boolean {
95 return db.transaction((): boolean => {
96 const moved = db
97 .query("UPDATE account_request SET state = ?2, decided_at = ?3, decided_by = ?4 WHERE user_id = ?1 AND state = 'waiting' RETURNING user_id")
98 .all(userId, to, at, by);
99 if (moved.length === 0) return false;
100 if (to === "allowed") db.query("UPDATE user SET suspended_at = NULL WHERE id = ?1").run(userId);
101 return true;
102 })();
103 }
104
105 /** The person has been told "You're in": true once. */
106 export function greet(db: Database, userId: string, at: string): boolean {
107 return (
108 db
109 .query("UPDATE account_request SET greeted_at = ?2 WHERE user_id = ?1 AND state = 'allowed' AND greeted_at IS NULL RETURNING user_id")
110 .all(userId, at).length === 1
111 );
112 }
src/core/event_audience.ts +3
@@ -156,6 +156,9 @@ const READER_KINDS: ReadonlySet<string> = new Set([
156156 "telemetry.revoked",
157157 "account.suspended",
158158 "account.unsuspended",
159 "account.requested",
160 "account.allowed",
161 "account.declined",
159162 "org.retired",
160163 "account.removed",
161164 "branch.created",
src/core/migration-pins/20261110170000-account-requests.pin added +1
@@ -0,0 +1,1 @@
1 595f52026600cc121defbdf9d4bb8bf23fb70a273a13963814dcf0d5180513b8
src/core/migrations/20261110170000-account-requests.ts added +25
@@ -0,0 +1,25 @@
1 // A stranger's request for an account (#10928): one row per person who asked
2 // while signup is `request`. The account itself is held suspended while its
3 // request is waiting or declined, so every credential check already refuses
4 // it; Allow lifts that. Additive.
5 export default `
6 CREATE TABLE account_request (
7 user_id TEXT PRIMARY KEY REFERENCES user(id) ON DELETE CASCADE,
8 state TEXT NOT NULL CHECK (state IN ('waiting', 'allowed', 'declined')),
9 note TEXT NOT NULL DEFAULT '',
10 via TEXT NOT NULL DEFAULT '',
11 created_at TEXT NOT NULL,
12 decided_at TEXT,
13 decided_by TEXT,
14 greeted_at TEXT
15 );
16 CREATE INDEX account_request_state ON account_request (state, created_at);
17 -- The owner asked (#10928) that a forge now closed take requests instead.
18 -- A forge that stored another value, or none, is left as it is.
19 INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at)
20 SELECT 'signup', 'closed', 'request', 'migration', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
21 FROM forge_setting WHERE key = 'signup' AND value = 'closed';
22 UPDATE forge_setting SET value = 'request', updated_by = 'migration',
23 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
24 WHERE key = 'signup' AND value = 'closed';
25 `;
src/core/users.ts +35 −6
@@ -12,6 +12,7 @@ import { id } from "../kernel/id.ts";
1212 import { err, ok, type Result } from "../kernel/result.ts";
1313 import { admitSignin } from "./signin-window.ts";
1414 import { getSignup } from "../settings/signup.ts";
15 import { openRequest } from "./account_requests.ts";
1516 import { inviteOpen, redeemInvite, reopenInvite } from "./invites.ts";
1617 import type { SignupSlot } from "../actions/signup-window.ts";
1718
@@ -136,7 +137,14 @@ export type AccountOrigin =
136137 * `invite` is the token of an invite link (`core/invites.ts`): while
137138 * signup is `invite` it opens the door once, spent with the account.
138139 */
139 | { origin: "signup" | "passkey"; slot: SignupSlot; invite?: string | undefined }
140 | {
141 origin: "signup" | "passkey";
142 slot: SignupSlot;
143 invite?: string | undefined;
144 /** While signup is `request`: what the person wants to build, and where the ask came from. */
145 note?: string | undefined;
146 via?: string | undefined;
147 }
140148 | { origin: "edge" }
141149 | { origin: Exclude<ExemptOrigin, AdminOrigin> }
142150 | ({ origin: AdminOrigin } & AdminFields);
@@ -168,7 +176,8 @@ export function newAccountsClosed(db: Database, invite?: string): boolean {
168176
169177 /** Whether a stranger needs an invite (or cannot come in at all) now. */
170178 function inviteNeeded(db: Database): boolean {
171 return getSignup(db) !== "open";
179 const mode = getSignup(db);
180 return mode !== "open" && mode !== "request";
172181 }
173182
174183 interface CreateUserFields {
@@ -230,9 +239,9 @@ export function discardFreshUser(db: Database, userId: string): void {
230239 */
231240 type Admission =
232241 | { readonly admitted: false }
233 | { readonly admitted: true; readonly spend: string | undefined };
242 | { readonly admitted: true; readonly spend: string | undefined; readonly waiting: boolean };
234243
235 const ADMITTED: Admission = { admitted: true, spend: undefined };
244 const ADMITTED: Admission = { admitted: true, spend: undefined, waiting: false };
236245 const REFUSED: Admission = { admitted: false };
237246
238247 function admission(db: Database, input: AccountOrigin): Admission {
@@ -240,12 +249,19 @@ function admission(db: Database, input: AccountOrigin): Admission {
240249 case "signup":
241250 case "passkey":
242251 if (input.slot.spent) throw new Error("a sign-up slot makes one account");
252 if (getSignup(db) === "request") {
253 // An open invite link still lets a person straight in; anyone else asks.
254 if (input.invite !== undefined && inviteOpen(db, input.invite))
255 return { admitted: true, spend: input.invite, waiting: false };
256 return { admitted: true, spend: undefined, waiting: true };
257 }
243258 if (!inviteNeeded(db)) return ADMITTED;
244259 return newAccountsClosed(db, input.invite) || input.invite === undefined
245260 ? REFUSED
246 : { admitted: true, spend: input.invite };
261 : { admitted: true, spend: input.invite, waiting: false };
247262 case "edge":
248 return newAccountsClosed(db) ? REFUSED : ADMITTED;
263 // The edge vouches for a login, not for a right to an account: only `open` lets it make one.
264 return getSignup(db) === "open" ? ADMITTED : REFUSED;
249265 case "claim":
250266 case "everyday":
251267 case "agent":
@@ -325,6 +341,7 @@ export async function createUser(
325341 const invite = admitted.spend;
326342 const insert = db.transaction((): boolean => {
327343 if (invite !== undefined && !redeemInvite(db, invite, userId)) return false;
344 const now = new Date().toISOString();
328345 db.query(
329346 `INSERT INTO user (id, username, email, password_hash, display_name, is_admin, is_agent)
330347 VALUES (?1, ?2, ?3, ?4, ?5,
@@ -343,6 +360,9 @@ export async function createUser(
343360 admin.adminIfFirst ? 1 : 0,
344361 agent ? 1 : 0,
345362 );
363 // A request waits, held suspended, in the same write as the account.
364 if (admitted.waiting)
365 openRequest(db, userId, "note" in input ? (input.note ?? "") : "", "via" in input ? (input.via ?? "") : "", now);
346366 return true;
347367 });
348368 try {
@@ -490,6 +510,15 @@ export interface PasswordAttempt {
490510 readonly source: string;
491511 }
492512
513 /**
514 * A bare argon2 match, for the one door that answers a person whose account is
515 * not yet allowed and so cannot sign in. Only called after the throttled check
516 * has refused, so a guess costs what it always did.
517 */
518 export async function passwordMatches(password: string, hash: string): Promise<boolean> {
519 return Bun.password.verify(password, hash).catch(() => false);
520 }
521
493522 /**
494523 * The only way to prove a password. The attempt is counted in the sign-in
495524 * windows (per account, per source, per forge: `core/signin-window.ts`)
src/crew/notify.ts +2 −1
@@ -21,7 +21,7 @@
2121 */
2222
2323 import { blockedLines } from "../actions/egress_refused.ts";
24 import { newAccountLines } from "../actions/accounts.ts";
24 import { newAccountLines, requestLines } from "../actions/accounts.ts";
2525 import { mailCeilingLines } from "../actions/mail.ts";
2626 import { forgetOnboarding, onboardedLines } from "../apps/onboarding.ts";
2727 import type { Database } from "bun:sqlite";
@@ -501,6 +501,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
501501 // to itself.
502502 const asks: { line: string; items: readonly string[]; link?: string }[] = [
503503 ...askLinesFor(app.db, user).map((a) => ({ line: a.line, items: a.items })),
504 ...requestLines(app.db, user),

MEDIUM: one phone line per waiting stranger, unbounded. A flood of requests floods the admin. Collapse to one line with a count.

504505 ...newAccountLines(app.db, user, now()),
505506 ...landedLines(app.db, user, now()),
506507 ...mailCeilingLines(app, user, now()),
src/http/api.ts +6
@@ -125,6 +125,7 @@ import {
125125 HUMAN_CHECK_MESSAGE,
126126 mintConversationToken,
127127 PASSKEY_REQUIRED_MESSAGE,
128 isWaiting,
128129 signUp,
129130 SIGNUP_CLOSED_MESSAGE,
130131 } from "../actions/users.ts";
@@ -1192,11 +1193,14 @@ export function apiRoutes(router: Router<Handler>): void {
11921193 email: str(input.email),
11931194 password: str(input.password),
11941195 humanToken: str(input.turnstile_token),
1196 note: str(input.note),
11951197 },
11961198 {
11971199 slot,
11981200 invite,
11991201 address: clientAddress(ctx.request),
1202 // Where the ask came from: a forge or an agent names itself; otherwise the API.
1203 via: str(input.from).trim() === "" ? "api" : `api: ${str(input.from).trim().slice(0, 60)}`,
12001204 },
12011205 );
12021206 if (!created.ok && created.error === "signup_closed")
@@ -1206,6 +1210,8 @@ export function apiRoutes(router: Router<Handler>): void {
12061210 if (!created.ok && created.error === "human_check")
12071211 return apiError("human_check", 422, HUMAN_CHECK_MESSAGE);
12081212 if (!created.ok) return json({ error: created.error }, 422);
1213 // A request waits: 202, and nothing to sign in with until an admin allows it.
1214 if (isWaiting(ctx.app, created.value.id)) return json({ state: "waiting" }, 202);
12091215 return json(publicUser(created.value), 201);
12101216 });
12111217
src/http/server.ts +4
@@ -140,6 +140,8 @@ import {
140140 signinPage,
141141 signinSubmit,
142142 signupPage,
143 signupRequestedPage,
144 signupWelcomePage,
143145 signupSubmit,
144146 verifyPage,
145147 themeSubmit,
@@ -860,6 +862,8 @@ export function buildRouter(): Router<Handler> {
860862 router.add("GET", "/signin/parent", parentStart);
861863 router.add("GET", "/signin/parent/callback", parentCallback);
862864 router.add("GET", "/signup", signupPage);
865 router.add("GET", "/signup/requested", signupRequestedPage);
866 router.add("GET", "/signup/welcome", signupWelcomePage);
863867 router.add("GET", "/verify", verifyPage);
864868 router.add("POST", "/signup", signupSubmit);
865869 router.add("POST", "/logout", logoutSubmit);
src/http/settings_routes.ts +3 −1
@@ -72,7 +72,7 @@ import {
7272 subscriptionHeadroom,
7373 subscriptionWake,
7474 } from "../web/pages/subscriptions.ts";
75 import { peoplePage, roleAct } from "../web/pages/people.ts";
75 import { peoplePage, requestAct, roleAct } from "../web/pages/people.ts";
7676 import { ceilingAct, grantAct, modelAdd, modelPage } from "../web/pages/model.ts";
7777 import {
7878 everydayCreate,
@@ -241,6 +241,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
241241 router.add("GET", "/settings/accounts", accountsPage);
242242 router.add("GET", "/settings/people", peoplePage);
243243 router.add("POST", "/settings/people/:username/role", roleAct);
244 router.add("POST", "/settings/people/:username/allow", requestAct);
245 router.add("POST", "/settings/people/:username/decline", requestAct);
244246 router.add("GET", "/settings/model", modelPage);
245247 router.add("POST", "/settings/model", modelAdd);
246248 router.add("POST", "/settings/accounts/:username/model", grantAct);
src/settings/signup.ts +12 −3
@@ -1,5 +1,6 @@
11 /**
2 * Who may make an account: `open`, `invite` or `closed`. Stored in
2 * Who may make an account: `open`, `request`, `invite` or `closed`. `request`
3 * lets a stranger ask: the account waits until an admin allows it (#10928). Stored in
34 * `forge_setting` under `signup`, and every change is kept in
45 * `forge_setting_change` (who, when, from what to what). Default `open` when nothing is stored; a new forge stores `invite` (`seedSignup`).
56 * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask
@@ -11,10 +12,18 @@ import type { Database } from "bun:sqlite";
1112 import { err, ok, type Result } from "../kernel/result.ts";
1213 import { writeSetting } from "./limits.ts";
1314
14 export const SIGNUP_MODES = ["open", "invite", "closed"] as const;
15 export const SIGNUP_MODES = ["open", "request", "invite", "closed"] as const;
1516 export type SignupMode = (typeof SIGNUP_MODES)[number];
1617 export const SIGNUP_DEFAULT: SignupMode = "open";
1718
19 /** What each mode means, in one line, for the knob and the terminal. */
20 export const SIGNUP_MEANS: Record<SignupMode, string> = {
21 open: "Anyone can make an account at once.",
22 request: "Anyone can ask for an account; an admin allows or declines each one.",
23 invite: "Only people with an invite link can make an account.",
24 closed: "Nobody can make an account, invite links included.",
25 };
26
1827 /** The actor kept for a change made by `FORGE_SIGNUP` at boot. */
1928 export const BOOT_ACTOR = "boot";
2029
@@ -66,7 +75,7 @@ export function signupFromEnv(raw: string | undefined): Result<SignupMode | null
6675 const parsed = parseSignup(s);
6776 return parsed.ok
6877 ? parsed
69 : err(`FORGE_SIGNUP: open, invite or closed, not "${s.slice(0, 20)}"`);
78 : err(`FORGE_SIGNUP: open, request, invite or closed, not "${s.slice(0, 20)}"`);
7079 }
7180
7281 export interface SignupChange {
src/web/pages/admin-limits.ts +6 −2
@@ -12,7 +12,7 @@ import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as
1212 import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts";
1313 import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts";
1414 import { changeSignup, readSignup } from "../../actions/signup.ts";
15 import { SIGNUP_MODES } from "../../settings/signup.ts";
15 import { SIGNUP_MEANS, SIGNUP_MODES } from "../../settings/signup.ts";
1616 import { QUOTA_KEYS } from "../../settings/quotas.ts";
1717 import type { Ctx } from "../../http/context.ts";
1818 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
@@ -122,7 +122,7 @@ export function limitsPage(ctx: Ctx): Response {
122122 }),
123123 );
124124 const signupRow = html`<div class="status-row">
125 <dt>Signup</dt>
125 <dt>New accounts</dt>
126126 <dd>
127127 <form class="row-fields" method="post" action="/admin/signup">
128128 <select class="input mono" name="signup" aria-label="Who may sign up">
@@ -132,6 +132,10 @@ export function limitsPage(ctx: Ctx): Response {
132132 </select>
133133 ${btn("Save", { size: "sm", variant: "primary", label: "Save signup" })}
134134 </form>
135 <ul class="small muted" id="signup-means">
136 ${SIGNUP_MODES.map((m) => html`<li><span class="mono">${m}</span>: ${SIGNUP_MEANS[m]}</li>`)}
137 </ul>
138 <p class="small muted">Invite links work in every mode except closed.</p>

Principle 1: the meanings list above already says this; the sentence explains itself twice. Drop it or fold it into the list.

135139 </dd>
136140 <dd class="status-act"></dd>
137141 </div>`;
src/web/pages/people.ts +63 −3
@@ -7,7 +7,9 @@
77 * username wraps anywhere, so the page never scrolls sideways (#9374).
88 */
99
10 import { allowRequest, declineRequest, listWaiting } from "../../actions/requests.ts";
1011 import { setRole } from "../../actions/roles.ts";
12 import type { AccountRequest } from "../../core/account_requests.ts";
1113 import { GRANT_MAX_USD } from "../../core/model_grant.ts";
1214 import { listPeople, roleLimits, type Person } from "../../core/roles.ts";
1315 import { getUserByName, type User } from "../../core/users.ts";
@@ -15,9 +17,9 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t
1517 import type { Ctx } from "../../http/context.ts";
1618 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
1719 import { field, readForm } from "../form.ts";
18 import { html } from "../html.ts";
20 import { html, type Html } from "../html.ts";
1921 import { flashTo, page } from "../layout.ts";
20 import { badge, btn, empty, userLink } from "../ui.ts";
22 import { badge, btn, empty, time, userLink } from "../ui.ts";
2123 import { accountSections } from "./origins.ts";
2224
2325 /**
@@ -36,6 +38,60 @@ export function appsMade(ctx: Ctx): Map<string, string[]> {
3638 return made;
3739 }
3840
41 /** Where a decision may send the admin back to: the people list or home, nowhere else. */
42 const BACKS = new Set(["/settings/people", "/"]);
43
44 /**
45 * Who is waiting for an account: name, what they want to build, when, and
46 * from where, with Allow and Decline one tap each. Nothing when nobody waits.
47 * Shown on this page and on the admin's home; `back` is where a tap returns.
48 */
49 export function waitingList(waiting: readonly AccountRequest[], back: string): Html | null {
50 if (waiting.length === 0) return null;
51 const form = (r: AccountRequest, what: "allow" | "decline") =>
52 html`<form method="post" action="/settings/people/${encodeURIComponent(r.username)}/${what}">
53 <input type="hidden" name="back" value="${back}" />
54 ${btn(what === "allow" ? "Allow" : "Decline", {
55 type: "submit",
56 size: "sm",
57 ...(what === "allow" ? { variant: "primary" as const } : {}),
58 label: `${what === "allow" ? "Allow" : "Decline"} ${r.username}`,
59 })}
60 </form>`;
61 return html`<section class="settings-block" id="waiting">
62 <h2>Waiting <span class="muted small">${waiting.length}</span></h2>
63 <ul class="stack">
64 ${waiting.map(
65 (r) => html`<li class="waiting-request">
66 <div><strong>${r.username}</strong> <span class="small muted">${time(r.createdAt)}${r.via === "" ? "" : html` · from ${r.via}`}</span></div>
67 ${r.note === "" ? html`<p class="small muted">No note.</p>` : html`<p class="small">${r.note}</p>`}
68 <div class="row-fields">${form(r, "allow")}${form(r, "decline")}</div>
69 </li>`,
70 )}
71 </ul>
72 </section>`;
73 }
74
75 // POST /settings/people/:username/allow and /decline
76 export async function requestAct(ctx: Ctx): Promise<Response> {
77 if (!ctx.auth.user) return signInFirst(ctx);
78 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
79 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
80 const form = await readForm(ctx, "admin");
81 if (form instanceof Response) return form;
82 const username = ctx.params.username ?? "";
83 const allow = ctx.url.pathname.endsWith("/allow");
84 const asked = field(form, "back");
85 const back = BACKS.has(asked) ? asked : "/settings/people";
86 const done = (allow ? allowRequest : declineRequest)(ctx.app, ctx.auth.user, username);
87 if (!done.ok && done.error === "not_found") return notFoundPage(ctx);
88 if (!done.ok)
89 return flashTo(ctx, back, { tone: "danger", message: `${username}'s request was already answered.` });
90 return flashTo(ctx, back, {
91 message: allow ? `${username} is in.` : `${username} was declined, and has been told so politely.`,
92 });
93 }
94
3995 // GET /settings/people
4096 export function peoplePage(ctx: Ctx): Response {
4197 if (!ctx.auth.user) return signInFirst(ctx);
@@ -43,6 +99,10 @@ export function peoplePage(ctx: Ctx): Response {
4399 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
44100 const rows = timePhase(ctx.request, "people", () => listPeople(ctx.app.db));
45101 const made = appsMade(ctx);
102 const waiting = timePhase(ctx.request, "waiting", () => {
103 const l = listWaiting(ctx.app, ctx.auth.user);
104 return l.ok ? l.value : [];
105 });
46106 const rendering = performance.now();
47107 const line = (p: Person) => {
48108 const role = p.role;
@@ -79,7 +139,7 @@ export function peoplePage(ctx: Ctx): Response {
79139 body: html`<div class="page-head"><h1>People</h1></div>
80140 ${settingsLayout(
81141 accountSections("people", { admin: true }),
82 html`<section class="settings-block">
142 html`${waitingList(waiting, "/settings/people")}<section class="settings-block">
83143 <h2>Guests, members and admins</h2>
84144 <p class="muted">Make a guest a member, with an allowance if you like.</p>
85145 ${
src/web/pages/signup-passkey.ts +5 −3
@@ -7,7 +7,7 @@
77 */
88
99 import { newChallenge, verifyRegistration } from "../../auth/webauthn.ts";
10 import { givenEmail, signUpWithPasskey, signupClosed, HUMAN_CHECK_MESSAGE, SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts";
10 import { givenEmail, isWaiting, 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";
@@ -102,11 +102,13 @@ export async function signupPasskey(ctx: Ctx): Promise<Response> {
102102 if (typeof slot === "number") return tooMany(slot);
103103 const created = await signUpWithPasskey(
104104 ctx.app,
105 { username, email, passkey: made, passkeyName: "Passkey", humanToken: str(data.humanToken) },
106 { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request) },
105 { username, email, passkey: made, passkeyName: "Passkey", humanToken: str(data.humanToken), note: str(data.note) },
106 { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request), via: "web" },
107107 );
108108 if (!created.ok)
109109 return refuse(created.error === "human_check" ? 422 : created.error === "signup_closed" ? 403 : 409, WORDS[created.error] ?? "Sign-up did not work. Try again.");
110 // A request waits: no session, and the next page says it is in.
111 if (isWaiting(ctx.app, created.value.id)) return json({ ok: true, next: "/signup/requested" }, 201);
110112 const session = createSession(ctx.app.db, created.value.id);
111113 return json(
112114 { ok: true, next: safeNext(ctx.url.searchParams.get("next")) ?? "/" },
src/web/pages/site.ts +78 −4
@@ -18,7 +18,9 @@ import { verifyEmail } from "../../actions/verify.ts";
1818 import { STRANGER_MAX } from "../../http/limits.ts";
1919 import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts";
2020 import { REPO_INIT_FAILED, REPO_NAME_RULES } from "../../actions/repo_words.ts";
21 import { HUMAN_CHECK_MESSAGE, INVITE_SPENT_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
21 import { HUMAN_CHECK_MESSAGE, INVITE_SPENT_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, isWaiting, signUp, signupClosed } from "../../actions/users.ts";
22 import { pendingFor } from "../../actions/requests.ts";
23 import { greet, requestOf, type AccountRequest } from "../../core/account_requests.ts";
2224 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2325 import { listPins } from "../../core/pins.ts";
2426 import { getReposByIds } from "../../core/repos.ts";
@@ -219,6 +221,7 @@ export async function home(ctx: Ctx): Promise<Response> {
219221 interface AuthValues {
220222 username?: string;
221223 email?: string;
224 note?: string;
222225 }
223226
224227 interface AuthErrors {
@@ -231,6 +234,7 @@ interface AuthErrors {
231234 /** What `/signin` says about new accounts in each signup mode; open says nothing. */
232235 const SIGNUP_SENTENCES: Record<SignupMode, string | undefined> = {
233236 open: undefined,
237 request: "New accounts here are looked at first: ask for one and someone who runs this forge will answer.",
234238 invite: "This forge is invite-only: new accounts need an invite link.",
235239 closed: "This forge is closed to new accounts.",
236240 };
@@ -343,6 +347,16 @@ function authForm(
343347 ...(errors.email ? { error: errors.email } : {}),
344348 }),
345349 )}
350 ${when(kind === "signup" && getSignup(ctx.app.db) === "request" && inviteOf(ctx) === undefined, () =>
351 formField({
352 label: "What do you want to build?",
353 name: "note",
354 type: "textarea",
355 rows: 3,
356 value: values.note ?? "",
357 hint: "Optional. A line or two helps whoever reads your request.",
358 }),
359 )}
346360 ${when(!byPasskey, () =>
347361 formField({
348362 label: "Password",
@@ -556,6 +570,9 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> {
556570 return response;
557571 }
558572 if (!verified.ok) {
573 // The right password for an account still waiting (or declined) is told where it stands, with no session.
574 const asked = await pendingFor(ctx.app, username, field(form, "password"));

HIGH: this is reached on every failed sign-in, throttled or not. See requests.ts: gate it on the failure reason.

575 if (asked !== undefined) return requestPage(ctx, asked.state === "declined" ? "Request declined" : "Request in", requestWords(asked));
559576 return page(
560577 {
561578 ctx,
@@ -574,8 +591,10 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> {
574591 );
575592 }
576593 const session = createSession(ctx.app.db, verified.value.id);
594 // The first time in after being allowed: say so once.
595 const welcome = requestOf(ctx.app.db, verified.value.id)?.state === "allowed" && greet(ctx.app.db, verified.value.id, new Date().toISOString());
577596 return redirect(
578 safeNext(ctx.url.searchParams.get("next")) ?? "/",
597 welcome ? "/signup/welcome" : (safeNext(ctx.url.searchParams.get("next")) ?? "/"),
579598 sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
580599 );
581600 }
@@ -673,8 +692,9 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
673692 email,
674693 password: field(form, "password"),
675694 humanToken: field(form, "cf-turnstile-response"),
695 note: field(form, "note"),
676696 },
677 { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request) },
697 { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request), via: "web" },
678698 );
679699 if (!created.ok) {
680700 if (created.error === "signup_closed") return signupClosedPage(ctx);
@@ -695,13 +715,15 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
695715 {
696716 ctx,
697717 title: "Sign up",
698 body: authForm(ctx, "signup", { values: { username, email }, errors }),
718 body: authForm(ctx, "signup", { values: { username, email, note: field(form, "note") }, errors }),
699719 turnstile: siteKey(ctx) !== undefined,
700720 scriptJs: passkeyJs,
701721 },
702722 422,
703723 );
704724 }
725 // A request waits: no session, only the page that says it is in.
726 if (isWaiting(ctx.app, created.value.id)) return redirect("/signup/requested");
705727 const session = createSession(ctx.app.db, created.value.id);
706728 return redirect(
707729 safeNext(ctx.url.searchParams.get("next")) ?? "/",
@@ -709,6 +731,58 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
709731 );
710732 }
711733
734 /** What a person is told once their request is in; general, for people who do not know whoever answers. */
735 export const REQUEST_IN_MESSAGE =
736 "Your request is in. Someone who runs this forge will look at it; you can close this page and sign in later to check.";
737
738 /** What a person is told when their request was declined. */
739 export const REQUEST_DECLINED_MESSAGE =
740 "Thank you for asking. Your request was not accepted this time, and no account is open for you here. You can still run a forge of your own, with no account here.";
741
742 /** The words for an account that has not been allowed, by where its request stands. */
743 export const requestWords = (r: AccountRequest): string =>
744 r.state === "declined" ? REQUEST_DECLINED_MESSAGE : REQUEST_IN_MESSAGE;
745
746 function requestPage(ctx: Ctx, title: string, message: string, status = 200): Response {
747 return timePhase(ctx.request, "render", () =>
748 page(
749 {
750 ctx,
751 title,
752 body: html`<div class="narrow stack" id="request-state">
753 ${pageHead(title)}
754 <p>${message}</p>
755 <div class="form-actions">
756 ${btn("Sign in", { href: "/signin", variant: "outline" })}
757 ${btn("Get your own forge", { href: "/#yours", variant: "primary" })}
758 </div>
759 </div>`,
760 },
761 status,
762 ));
763 }
764
765 // GET /signup/requested — static: it names nobody and reads no account.
766 export function signupRequestedPage(ctx: Ctx): Response {
767 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
768 return requestPage(ctx, "Request sent", REQUEST_IN_MESSAGE);
769 }
770
771 // GET /signup/welcome — the first sign-in after an admin allowed the request.
772 export function signupWelcomePage(ctx: Ctx): Response {
773 if (!ctx.auth.user) return redirect("/signin");
774 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
775 return timePhase(ctx.request, "render", () => page({
776 ctx,
777 title: "You're in",
778 body: html`<div class="narrow stack" id="request-allowed">
779 ${pageHead("You're in.")}
780 <p>Your request was allowed. Your account is ready.</p>
781 <div class="form-actions">${btn("Go to the forge", { href: "/", variant: "primary" })}</div>
782 </div>`,
783 }));
784 }
785
712786 // POST /theme — persist the theme choice in a cookie. Works as a plain form
713787 // submit (redirect back to the referring page) and as a background fetch from
714788 // the switcher script; "system" clears the cookie so the OS decides again.
src/web/passkey-client.ts +1
@@ -131,6 +131,7 @@ export const passkeyJs = /* js */ `
131131 return post('/signup/passkey' + location.search, {
132132 username: who.username,
133133 email: who.email,
134 note: val('note'),
134135 humanToken: val('cf-turnstile-response'),
135136 challenge: o.challenge,
136137 clientDataJSON: text(cred.response.clientDataJSON),
src/web/standards.ts +1 −1
@@ -411,7 +411,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
411411 "",
412412 "If you are an assistant helping a person: say plainly what you can and cannot do from where you are. If you cannot reach this forge or hold an account on it, give the person these steps to do by hand.",
413413 "",
414 `- Help build a public app here: each one is a repository at ${o}/{owner}/{name}. A person makes an account at ${o}/signup, then follows "Offer a change" below. If /signup answers 403, new accounts here are by invitation for now: send a suggestion through "Report a problem" below, or work from a forge of your own.`,
414 `- Help build a public app here: each one is a repository at ${o}/{owner}/{name}. A person makes an account at ${o}/signup, then follows "Offer a change" below. Where the forge takes requests, \`POST ${o}/api/v1/users\` (username, password, optional \`note\` of what they want to build) answers 202 \`{"state":"waiting"}\`: the account waits until someone who runs the forge allows it, and the person can sign in later to check. If /signup answers 403, new accounts here are by invitation for now: send a suggestion through "Report a problem" below, or work from a forge of your own.`,
415415 `- Get your own forge: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, free within limits while hosting is not sold: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`,
416416 `- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: ${UPSTREAM_REPO}/raw/main/docs/ORIGINS.md`,
417417 `- Connect an assistant that can use tools: add a custom connector with the URL ${mcp.url}. It signs in with OAuth; only the report tool works without an account.`,
src/web/studio/home.ts +11 −2
@@ -21,6 +21,8 @@ import {
2121 import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts";
2222 import { appsRuntime, type EnvState } from "../../apps/runtime.ts";
2323 import { signupClosed } from "../../actions/users.ts";
24 import { listWaiting } from "../../actions/requests.ts";
25 import { waitingList } from "../pages/people.ts";
2426 import { getSignup } from "../../settings/signup.ts";
2527 import { appsForRepos } from "../../core/apps.ts";
2628 import { WEIGHT_MAX, WEIGHT_NAMES } from "../../core/front.ts";
@@ -351,7 +353,14 @@ export async function studioHome(
351353 isNavigation(ctx.request) ? noteVisit(ctx.app.db, user.id, visitedAt) : lastVisit(ctx.app.db, user.id),
352354 );
353355 const since = timePhase(ctx.request, "since", () => sinceYouWere(ctx.app.db, user, before, visitedAt));
354 const needsYou = html`${waiting}${held}${alarms}`;
356 // Someone asking for an account: Allow or Decline, one tap, on the phone's first screen.
357 const asking = user.isAdmin
358 ? timePhase(ctx.request, "requests", () => {
359 const l = listWaiting(ctx.app, user);
360 return waitingList(l.ok ? l.value : [], "/");
361 })
362 : null;
363 const needsYou = html`${asking}${waiting}${held}${alarms}`;
355364 const body = html`<h1 class="sr-only">Home</h1>
356365 ${tellBox(ctx, user)}
357366 ${ask} ${address}
@@ -372,7 +381,7 @@ export async function studioHome(
372381 body,
373382 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${refreshJs}\n${registerWorkerJs}${ask ? `\n${passkeyJs}` : ""}`,
374383 });
375 notePhaseRest(ctx.request, "render", rendering, ["kpis", "moving", "access", "waiting", "held", "visit", "since"]);
384 notePhaseRest(ctx.request, "render", rendering, ["kpis", "moving", "access", "waiting", "held", "visit", "since", "requests"]);
376385 return res;
377386 }
378387
test/request-access.test.ts added +182
@@ -0,0 +1,182 @@
1 /**
2 * Request access (#10928): with signup on `request` a stranger's account
3 * waits, the admin allows or declines it with one tap, and the person is told
4 * plainly where it stands. By API and by page, the Allow path and the Decline path.
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 { newAccountLines, requestLines } from "../src/actions/accounts.ts";
13 import { getUserByName } from "../src/core/users.ts";
14 import { requestOf, waitingCount } from "../src/core/account_requests.ts";
15 import { mintInvite } from "../src/core/invites.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { setLimit } from "../src/settings/limits.ts";
18 import { setSignup } from "../src/settings/signup.ts";
19 import { adminCookie } from "./helpers/admin.ts";
20
21 const ORIGIN = "https://forge.example";
22 const PASSWORD = "a-good-passphrase";
23
24 describe("request access", () => {
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let base: string;
29 let admin: string;
30
31 beforeAll(async () => {
32 dataDir = await mkdtemp(join(tmpdir(), "forge-request-access-"));
33 app = createApp({ dataDir, port: 0, url: ORIGIN });
34 servers = await serve(app);
35 setLimit(app.db, "signup.per_source", 1000, "setup");
36 setSignup(app.db, "request", "boot");
37 admin = await adminCookie(app, { username: "boss", email: "[email protected]" });
38 base = servers.url;
39 });
40 afterAll(async () => {
41 await servers.stop();
42 app.close();
43 await rm(dataDir, { recursive: true, force: true });
44 });
45
46 const api = (username: string, extra: Record<string, string> = {}) =>
47 fetch(`${base}/api/v1/users`, {
48 method: "POST",
49 headers: { "content-type": "application/json", origin: ORIGIN },
50 body: JSON.stringify({ username, email: `${username}@example.com`, password: PASSWORD, ...extra }),
51 });
52 const form = (path: string, fields: Record<string, string>, cookie?: string) =>
53 fetch(`${base}${path}`, {
54 method: "POST",
55 redirect: "manual",
56 headers: { "content-type": "application/x-www-form-urlencoded", origin: ORIGIN, ...(cookie ? { cookie } : {}) },
57 body: new URLSearchParams(fields),
58 });
59 const signin = (username: string) => form("/signin", { username, password: PASSWORD });
60
61 test("the form asks what you want to build, and names no owner", async () => {
62 const page = await fetch(`${base}/signup`).then((r) => r.text());
63 expect(page).toContain("What do you want to build?");
64 expect(page).toContain('name="password"');
65 });
66
67 test("API: 202 waiting, the account holds nothing, the admin hears at once", async () => {
68 const res = await api("ada", { note: "a tide table for my boat", from: "my-agent" });
69 expect(res.status).toBe(202);
70 expect(await res.json()).toEqual({ state: "waiting" });
71 const ada = getUserByName(app.db, "ada")!;
72 expect(requestOf(app.db, ada.id)).toMatchObject({ state: "waiting", note: "a tide table for my boat", via: "api: my-agent" });
73 // Nothing it can do: no credential works.
74 const token = await fetch(`${base}/api/v1/user`, { headers: { cookie: "forge_session=nope" } });
75 expect(token.status).toBe(401);
76 const boss = getUserByName(app.db, "boss")!;
77 expect(requestLines(app.db, boss).map((l) => l.line)).toEqual(["ada asked for an account: Allow or Decline"]);
78 expect(newAccountLines(app.db, boss).some((l) => l.id === "account:ada")).toBe(false);
79 // Anyone but an admin sees no list.
80 const home = await fetch(`${base}/`, { headers: { cookie: admin } }).then((r) => r.text());
81 expect(home).toContain("/settings/people/ada/allow");
82 expect(home).toContain("a tide table for my boat");
83 });
84
85 test("page: signing in with the right password says the request is in; no session is made", async () => {
86 const res = await signin("ada");
87 expect(res.status).toBe(200);
88 expect(res.headers.get("set-cookie")).toBeNull();
89 const html = await res.text();
90 expect(html).toContain("Your request is in. Someone who runs this forge will look at it; you can close this page and sign in later to check.");
91 expect(html).not.toContain("boss");
92 // A wrong password learns nothing about the request.
93 const wrong = await form("/signin", { username: "ada", password: "not-the-passphrase" });
94 expect(wrong.status).toBe(422);
95 expect(await wrong.text()).not.toContain("request is in");
96 });
97
98 test("a stranger cannot allow, a cross-origin post cannot allow", async () => {
99 const stranger = await form("/settings/people/ada/allow", {});
100 expect([302, 303, 401, 403, 404]).toContain(stranger.status);
101 const cross = await fetch(`${base}/settings/people/ada/allow`, {
102 method: "POST",
103 redirect: "manual",
104 headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example", cookie: admin },
105 body: "",
106 });
107 expect(cross.status).toBeGreaterThanOrEqual(400);
108 expect(requestOf(app.db, getUserByName(app.db, "ada")!.id)?.state).toBe("waiting");
109 });
110
111 test("Allow: one tap, then sign-in works and says You're in, once", async () => {
112 const people = await fetch(`${base}/settings/people`, { headers: { cookie: admin } }).then((r) => r.text());
113 expect(people).toContain("Waiting");
114 expect(people).toContain("a tide table for my boat");
115 const tap = await form("/settings/people/ada/allow", { back: "/" }, admin);
116 expect(tap.status).toBe(303);
117 expect(waitingCount(app.db)).toBe(0);
118 const again = await form("/settings/people/ada/allow", { back: "/" }, admin);
119 expect(again.status).toBe(303); // answered already: a flash, no change
120 const res = await signin("ada");
121 expect(res.status).toBe(303);
122 expect(res.headers.get("location")).toBe("/signup/welcome");
123 const cookie = res.headers.get("set-cookie")!.split(";")[0]!;
124 const welcome = await fetch(`${base}/signup/welcome`, { headers: { cookie } }).then((r) => r.text());
125 expect(welcome).toContain("You&#39;re in.");
126 const next = await signin("ada");
127 expect(next.headers.get("location")).toBe("/");
128 });
129
130 test("Decline: a record is kept, the person is told politely, no session", async () => {
131 expect((await api("bea", { note: "just looking" })).status).toBe(202);
132 const tap = await form("/settings/people/bea/decline", { back: "/settings/people" }, admin);
133 expect(tap.status).toBe(303);
134 const bea = getUserByName(app.db, "bea")!;
135 expect(requestOf(app.db, bea.id)).toMatchObject({ state: "declined", note: "just looking" });
136 const res = await signin("bea");
137 expect(res.status).toBe(200);
138 expect(res.headers.get("set-cookie")).toBeNull();
139 expect(await res.text()).toContain("Your request was not accepted this time");
140 // A declined request cannot be allowed by a second tap.
141 await form("/settings/people/bea/allow", {}, admin);
142 expect(requestOf(app.db, bea.id)?.state).toBe("declined");
143 });
144
145 test("page: the form makes a waiting account and sends to the request-in page", async () => {
146 const res = await form("/signup", { username: "cy", email: "", password: PASSWORD, note: "<b>a game</b>" });
147 expect(res.status).toBe(303);
148 expect(res.headers.get("location")).toBe("/signup/requested");
149 expect(res.headers.get("set-cookie")).toBeNull();
150 const page = await fetch(`${base}/signup/requested`).then((r) => r.text());
151 expect(page).toContain("Your request is in.");
152 const home = await fetch(`${base}/`, { headers: { cookie: admin } }).then((r) => r.text());
153 expect(home).toContain("&lt;b&gt;a game&lt;/b&gt;");
154 expect(home).not.toContain("<b>a game</b>");
155 });
156
157 test("an open invite link still lets a person straight in", async () => {
158 const boss = getUserByName(app.db, "boss")!;
159 const { token } = mintInvite(app.db, boss.id);
160 const res = await fetch(`${base}/api/v1/users?invite=${encodeURIComponent(token)}`, {
161 method: "POST",
162 headers: { "content-type": "application/json", origin: ORIGIN },
163 body: JSON.stringify({ username: "dee", password: PASSWORD, invite: token }),
164 });
165 expect(res.status).toBe(201);
166 });
167
168 test("open makes accounts at once; closed refuses even a request", async () => {
169 setSignup(app.db, "open", "boot");
170 expect((await api("eli")).status).toBe(201);
171 setSignup(app.db, "closed", "boot");
172 expect((await api("fay")).status).toBe(403);
173 setSignup(app.db, "request", "boot");
174 });
175
176 test("the knob shows what each mode means", async () => {
177 const page = await fetch(`${base}/admin/limits`, { headers: { cookie: admin } });
178 const html = await page.text();
179 expect(html).toContain("New accounts");
180 expect(html).toContain("Anyone can ask for an account; an admin allows or declines each one.");
181 });
182 });
test/tui-signup.test.ts +3 −3
@@ -74,14 +74,14 @@ test("reads open, esc changes nothing, a confirm closes the door", async () => {
7474 await h.open();
7575 expect(h.text()).toContain("signup: open");
7676 // picker, closed, then esc on the plan
77 await h.press("s", "j", "j", "enter");
77 await h.press("s", "j", "j", "j", "enter");
7878 expect(h.text()).toContain("from open to closed");
7979 await h.press("esc");
8080 expect(getSignup(forge.db)).toBe("open");
8181 // esc on the picker too
8282 await h.press("s", "esc");
8383 expect(getSignup(forge.db)).toBe("open");
84 await h.press("s", "j", "j", "enter", "enter");
84 await h.press("s", "j", "j", "j", "enter", "enter");
8585 expect(getSignup(forge.db)).toBe("closed");
8686 expect(signupClosed({ db: forge.db } as never)).toBe(true);
8787 expect(h.text()).toContain("signup: closed");
@@ -100,7 +100,7 @@ test("a non-admin and an admin agent are never offered s", async () => {
100100 const h = boot(who);
101101 await h.open();
102102 expect(h.text()).not.toContain("signup:");
103 await h.press("s", "j", "j", "enter", "enter");
103 await h.press("s", "j", "j", "j", "enter", "enter");
104104 expect(getSignup(forge.db)).toBe("open");
105105 h.stop();
106106 const user = getUserByName(forge.db, who)!;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs