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
torque/forge:torque/10928-request-access-a-stranger-can-ask-for-an → main 2 commits fork 4 threads · 4 unresolved
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`; | ||
| 193 | 193 | | `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 | |
| 194 | 194 | | `sever` | — | Unlink a spawned forge from its parent (owner of its `forge` repo only) | 214 | |
| 195 | 195 | | `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 | | |
| 197 | 197 | | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 | |
| 198 | 198 | | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 | |
| 199 | 199 | | `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`, | ||
| 130 | 130 | - `/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). |
| 131 | 131 | - `/sign-in` — `http/server.ts` (`signinAlias`) · the same, hyphenated · it is `/signin` · none (308, no page of its own). |
| 132 | 132 | - `/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). | |
| 133 | 135 | - `/verify` — `pages/site.ts` · confirm an email address · none · colours 3 (file). |
| 134 | 136 | - `/recover` — `pages/passkeys.ts` · get back in without a password · `/settings/passkeys` · none seen. |
| 135 | 137 | - `/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 @@ | ||
| 12 | 12 | |
| 13 | 13 | import type { Database } from "bun:sqlite"; |
| 14 | 14 | import type { App } from "../app.ts"; |
| 15 | import { waitingRequests } from "../core/account_requests.ts"; | |
| 15 | 16 | import { getAppByRepo } from "../core/apps.ts"; |
| 16 | 17 | import { listTeams, retireOrg, soleOwnedOrgs } from "../core/orgs.ts"; |
| 17 | 18 | import { appsRuntime } from "../apps/runtime.ts"; |
| @@ -314,6 +315,22 @@ export function verifyAccount( | ||
| 314 | 315 | return ok(user); |
| 315 | 316 | } |
| 316 | 317 | |
| 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 | ||
| 317 | 334 | /** An account counts as new for this long after it is made. */ |
| 318 | 335 | export const NEW_FOR_MS = 7 * 24 * 3_600_000; |
| 319 | 336 | |
| @@ -329,9 +346,11 @@ export function newAccountLines( | ||
| 329 | 346 | ): { id: string; line: string; items: readonly string[] }[] { |
| 330 | 347 | if (!user.isAdmin || user.kind !== "user") return []; |
| 331 | 348 | const since = new Date(now - NEW_FOR_MS).toISOString(); |
| 349 | const asking = new Set(waitingRequests(db).map((r) => r.username)); | |
| 332 | 350 | return readAccounts(db) |
| 333 | 351 | .filter( |
| 334 | 352 | (a) => |
| 353 | !asking.has(a.username) && | |
| 335 | 354 | a.kind === "user" && |
| 336 | 355 | !a.isAdmin && |
| 337 | 356 | 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); | |
| 76 | return valid ? asked : undefined; | |
| 77 | } |
src/actions/users.ts +26 −1
| @@ -43,6 +43,7 @@ import { | ||
| 43 | 43 | } from "../core/users.ts"; |
| 44 | 44 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 45 | 45 | import { requireUser } from "./access.ts"; |
| 46 | import { requestOf } from "../core/account_requests.ts"; | |
| 46 | 47 | import type { SignupSlot } from "./signup-window.ts"; |
| 47 | 48 | |
| 48 | 49 | export interface SignUpInput { |
| @@ -52,6 +53,8 @@ export interface SignUpInput { | ||
| 52 | 53 | password: string; |
| 53 | 54 | /** The Turnstile token the browser's widget gave; read only when a secret is set. */ |
| 54 | 55 | humanToken?: string; |
| 56 | /** While signup is `request`: what they want to build (optional, bounded). */ | |
| 57 | note?: string; | |
| 55 | 58 | } |
| 56 | 59 | |
| 57 | 60 | export type SignUpError = |
| @@ -120,6 +123,8 @@ export async function signUp( | ||
| 120 | 123 | /** The token of the invite link the person came by (`core/invites.ts`). */ |
| 121 | 124 | invite?: string | undefined; |
| 122 | 125 | 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; | |
| 123 | 128 | }, |
| 124 | 129 | ): Promise<Result<User, SignUpError>> { |
| 125 | 130 | // A username and a password is a way in that stays, beside the passkey |
| @@ -148,6 +153,8 @@ export async function signUp( | ||
| 148 | 153 | origin: "signup", |
| 149 | 154 | slot: from.slot, |
| 150 | 155 | invite: from.invite, |
| 156 | note: input.note, | |
| 157 | via: from.via ?? "web", | |
| 151 | 158 | }); |
| 152 | 159 | if (!created.ok) return created; |
| 153 | 160 | app.bus.append({ |
| @@ -158,6 +165,7 @@ export async function signUp( | ||
| 158 | 165 | ...(from.address === undefined ? {} : { address: from.address }), |
| 159 | 166 | }, |
| 160 | 167 | }); |
| 168 | announceRequest(app, created.value.id); | |
| 161 | 169 | auditInvite(app, created.value.id); |
| 162 | 170 | // The address is proven by the link it is sent; until then nothing it |
| 163 | 171 | // does may create or cost. |
| @@ -172,6 +180,7 @@ export interface PasskeySignUpInput { | ||
| 172 | 180 | passkey: Credential; |
| 173 | 181 | passkeyName: string; |
| 174 | 182 | humanToken?: string; |
| 183 | note?: string; | |
| 175 | 184 | } |
| 176 | 185 | |
| 177 | 186 | /** |
| @@ -184,7 +193,7 @@ export interface PasskeySignUpInput { | ||
| 184 | 193 | export async function signUpWithPasskey( |
| 185 | 194 | app: App, |
| 186 | 195 | 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 }, | |
| 188 | 197 | ): Promise<Result<User, SignUpError | "passkey_taken">> { |
| 189 | 198 | if (signupClosed(app, from.invite)) return err("signup_closed"); |
| 190 | 199 | const secret = app.config.turnstileSecret; |
| @@ -202,6 +211,8 @@ export async function signUpWithPasskey( | ||
| 202 | 211 | origin: "passkey", |
| 203 | 212 | slot: from.slot, |
| 204 | 213 | invite: from.invite, |
| 214 | note: input.note, | |
| 215 | via: from.via ?? "web", | |
| 205 | 216 | }); |
| 206 | 217 | if (!created.ok) return created; |
| 207 | 218 | const kept = addPasskey(app.db, created.value.id, input.passkeyName, input.passkey); |
| @@ -217,12 +228,26 @@ export async function signUpWithPasskey( | ||
| 217 | 228 | ...(from.address === undefined ? {} : { address: from.address }), |
| 218 | 229 | }, |
| 219 | 230 | }); |
| 231 | announceRequest(app, created.value.id); | |
| 220 | 232 | auditInvite(app, created.value.id); |
| 221 | 233 | // As on the password path: no address given, no link to send. |
| 222 | 234 | if (email !== "") await welcome(app, created.value); |
| 223 | 235 | return created; |
| 224 | 236 | } |
| 225 | 237 | |
| 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 | ||
| 226 | 251 | /** Using an invite link is an event: the invite's id and who it let in, never its token. */ |
| 227 | 252 | function auditInvite(app: App, userId: string): void { |
| 228 | 253 | const inviteId = inviteSpentBy(app.db, userId); |
src/contract/openapi/routes/postApiV1Users.ts +3 −2
| @@ -4,11 +4,12 @@ export const route: Route = { | ||
| 4 | 4 | method: "post", |
| 5 | 5 | path: "/api/v1/users", |
| 6 | 6 | 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", | |
| 8 | 8 | auth: false, |
| 9 | 9 | role: "anonymous", |
| 10 | 10 | 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" }, | |
| 12 | 13 | refusals: { |
| 13 | 14 | rate_limited: "429, too many sign-ups; retry-after says when", |
| 14 | 15 | 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([ | ||
| 156 | 156 | "telemetry.revoked", |
| 157 | 157 | "account.suspended", |
| 158 | 158 | "account.unsuspended", |
| 159 | "account.requested", | |
| 160 | "account.allowed", | |
| 161 | "account.declined", | |
| 159 | 162 | "org.retired", |
| 160 | 163 | "account.removed", |
| 161 | 164 | "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"; | ||
| 12 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | 13 | import { admitSignin } from "./signin-window.ts"; |
| 14 | 14 | import { getSignup } from "../settings/signup.ts"; |
| 15 | import { openRequest } from "./account_requests.ts"; | |
| 15 | 16 | import { inviteOpen, redeemInvite, reopenInvite } from "./invites.ts"; |
| 16 | 17 | import type { SignupSlot } from "../actions/signup-window.ts"; |
| 17 | 18 | |
| @@ -136,7 +137,14 @@ export type AccountOrigin = | ||
| 136 | 137 | * `invite` is the token of an invite link (`core/invites.ts`): while |
| 137 | 138 | * signup is `invite` it opens the door once, spent with the account. |
| 138 | 139 | */ |
| 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 | } | |
| 140 | 148 | | { origin: "edge" } |
| 141 | 149 | | { origin: Exclude<ExemptOrigin, AdminOrigin> } |
| 142 | 150 | | ({ origin: AdminOrigin } & AdminFields); |
| @@ -168,7 +176,8 @@ export function newAccountsClosed(db: Database, invite?: string): boolean { | ||
| 168 | 176 | |
| 169 | 177 | /** Whether a stranger needs an invite (or cannot come in at all) now. */ |
| 170 | 178 | function inviteNeeded(db: Database): boolean { |
| 171 | return getSignup(db) !== "open"; | |
| 179 | const mode = getSignup(db); | |
| 180 | return mode !== "open" && mode !== "request"; | |
| 172 | 181 | } |
| 173 | 182 | |
| 174 | 183 | interface CreateUserFields { |
| @@ -230,9 +239,9 @@ export function discardFreshUser(db: Database, userId: string): void { | ||
| 230 | 239 | */ |
| 231 | 240 | type Admission = |
| 232 | 241 | | { readonly admitted: false } |
| 233 | | { readonly admitted: true; readonly spend: string | undefined }; | |
| 242 | | { readonly admitted: true; readonly spend: string | undefined; readonly waiting: boolean }; | |
| 234 | 243 | |
| 235 | const ADMITTED: Admission = { admitted: true, spend: undefined }; | |
| 244 | const ADMITTED: Admission = { admitted: true, spend: undefined, waiting: false }; | |
| 236 | 245 | const REFUSED: Admission = { admitted: false }; |
| 237 | 246 | |
| 238 | 247 | function admission(db: Database, input: AccountOrigin): Admission { |
| @@ -240,12 +249,19 @@ function admission(db: Database, input: AccountOrigin): Admission { | ||
| 240 | 249 | case "signup": |
| 241 | 250 | case "passkey": |
| 242 | 251 | 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 | } | |
| 243 | 258 | if (!inviteNeeded(db)) return ADMITTED; |
| 244 | 259 | return newAccountsClosed(db, input.invite) || input.invite === undefined |
| 245 | 260 | ? REFUSED |
| 246 | : { admitted: true, spend: input.invite }; | |
| 261 | : { admitted: true, spend: input.invite, waiting: false }; | |
| 247 | 262 | 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; | |
| 249 | 265 | case "claim": |
| 250 | 266 | case "everyday": |
| 251 | 267 | case "agent": |
| @@ -325,6 +341,7 @@ export async function createUser( | ||
| 325 | 341 | const invite = admitted.spend; |
| 326 | 342 | const insert = db.transaction((): boolean => { |
| 327 | 343 | if (invite !== undefined && !redeemInvite(db, invite, userId)) return false; |
| 344 | const now = new Date().toISOString(); | |
| 328 | 345 | db.query( |
| 329 | 346 | `INSERT INTO user (id, username, email, password_hash, display_name, is_admin, is_agent) |
| 330 | 347 | VALUES (?1, ?2, ?3, ?4, ?5, |
| @@ -343,6 +360,9 @@ export async function createUser( | ||
| 343 | 360 | admin.adminIfFirst ? 1 : 0, |
| 344 | 361 | agent ? 1 : 0, |
| 345 | 362 | ); |
| 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); | |
| 346 | 366 | return true; |
| 347 | 367 | }); |
| 348 | 368 | try { |
| @@ -490,6 +510,15 @@ export interface PasswordAttempt { | ||
| 490 | 510 | readonly source: string; |
| 491 | 511 | } |
| 492 | 512 | |
| 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 | ||
| 493 | 522 | /** |
| 494 | 523 | * The only way to prove a password. The attempt is counted in the sign-in |
| 495 | 524 | * windows (per account, per source, per forge: `core/signin-window.ts`) |
src/crew/notify.ts +2 −1
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | */ |
| 22 | 22 | |
| 23 | 23 | import { blockedLines } from "../actions/egress_refused.ts"; |
| 24 | import { newAccountLines } from "../actions/accounts.ts"; | |
| 24 | import { newAccountLines, requestLines } from "../actions/accounts.ts"; | |
| 25 | 25 | import { mailCeilingLines } from "../actions/mail.ts"; |
| 26 | 26 | import { forgetOnboarding, onboardedLines } from "../apps/onboarding.ts"; |
| 27 | 27 | import type { Database } from "bun:sqlite"; |
| @@ -501,6 +501,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 501 | 501 | // to itself. |
| 502 | 502 | const asks: { line: string; items: readonly string[]; link?: string }[] = [ |
| 503 | 503 | ...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. |
||
| 504 | 505 | ...newAccountLines(app.db, user, now()), |
| 505 | 506 | ...landedLines(app.db, user, now()), |
| 506 | 507 | ...mailCeilingLines(app, user, now()), |
src/http/api.ts +6
| @@ -125,6 +125,7 @@ import { | ||
| 125 | 125 | HUMAN_CHECK_MESSAGE, |
| 126 | 126 | mintConversationToken, |
| 127 | 127 | PASSKEY_REQUIRED_MESSAGE, |
| 128 | isWaiting, | |
| 128 | 129 | signUp, |
| 129 | 130 | SIGNUP_CLOSED_MESSAGE, |
| 130 | 131 | } from "../actions/users.ts"; |
| @@ -1192,11 +1193,14 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1192 | 1193 | email: str(input.email), |
| 1193 | 1194 | password: str(input.password), |
| 1194 | 1195 | humanToken: str(input.turnstile_token), |
| 1196 | note: str(input.note), | |
| 1195 | 1197 | }, |
| 1196 | 1198 | { |
| 1197 | 1199 | slot, |
| 1198 | 1200 | invite, |
| 1199 | 1201 | 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)}`, | |
| 1200 | 1204 | }, |
| 1201 | 1205 | ); |
| 1202 | 1206 | if (!created.ok && created.error === "signup_closed") |
| @@ -1206,6 +1210,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1206 | 1210 | if (!created.ok && created.error === "human_check") |
| 1207 | 1211 | return apiError("human_check", 422, HUMAN_CHECK_MESSAGE); |
| 1208 | 1212 | 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); | |
| 1209 | 1215 | return json(publicUser(created.value), 201); |
| 1210 | 1216 | }); |
| 1211 | 1217 |
src/http/server.ts +4
| @@ -140,6 +140,8 @@ import { | ||
| 140 | 140 | signinPage, |
| 141 | 141 | signinSubmit, |
| 142 | 142 | signupPage, |
| 143 | signupRequestedPage, | |
| 144 | signupWelcomePage, | |
| 143 | 145 | signupSubmit, |
| 144 | 146 | verifyPage, |
| 145 | 147 | themeSubmit, |
| @@ -860,6 +862,8 @@ export function buildRouter(): Router<Handler> { | ||
| 860 | 862 | router.add("GET", "/signin/parent", parentStart); |
| 861 | 863 | router.add("GET", "/signin/parent/callback", parentCallback); |
| 862 | 864 | router.add("GET", "/signup", signupPage); |
| 865 | router.add("GET", "/signup/requested", signupRequestedPage); | |
| 866 | router.add("GET", "/signup/welcome", signupWelcomePage); | |
| 863 | 867 | router.add("GET", "/verify", verifyPage); |
| 864 | 868 | router.add("POST", "/signup", signupSubmit); |
| 865 | 869 | router.add("POST", "/logout", logoutSubmit); |
src/http/settings_routes.ts +3 −1
| @@ -72,7 +72,7 @@ import { | ||
| 72 | 72 | subscriptionHeadroom, |
| 73 | 73 | subscriptionWake, |
| 74 | 74 | } from "../web/pages/subscriptions.ts"; |
| 75 | import { peoplePage, roleAct } from "../web/pages/people.ts"; | |
| 75 | import { peoplePage, requestAct, roleAct } from "../web/pages/people.ts"; | |
| 76 | 76 | import { ceilingAct, grantAct, modelAdd, modelPage } from "../web/pages/model.ts"; |
| 77 | 77 | import { |
| 78 | 78 | everydayCreate, |
| @@ -241,6 +241,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 241 | 241 | router.add("GET", "/settings/accounts", accountsPage); |
| 242 | 242 | router.add("GET", "/settings/people", peoplePage); |
| 243 | 243 | 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); | |
| 244 | 246 | router.add("GET", "/settings/model", modelPage); |
| 245 | 247 | router.add("POST", "/settings/model", modelAdd); |
| 246 | 248 | router.add("POST", "/settings/accounts/:username/model", grantAct); |
src/settings/signup.ts +12 −3
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 4 | * `forge_setting` under `signup`, and every change is kept in |
| 4 | 5 | * `forge_setting_change` (who, when, from what to what). Default `open` when nothing is stored; a new forge stores `invite` (`seedSignup`). |
| 5 | 6 | * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask |
| @@ -11,10 +12,18 @@ import type { Database } from "bun:sqlite"; | ||
| 11 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 12 | 13 | import { writeSetting } from "./limits.ts"; |
| 13 | 14 | |
| 14 | export const SIGNUP_MODES = ["open", "invite", "closed"] as const; | |
| 15 | export const SIGNUP_MODES = ["open", "request", "invite", "closed"] as const; | |
| 15 | 16 | export type SignupMode = (typeof SIGNUP_MODES)[number]; |
| 16 | 17 | export const SIGNUP_DEFAULT: SignupMode = "open"; |
| 17 | 18 | |
| 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 | ||
| 18 | 27 | /** The actor kept for a change made by `FORGE_SIGNUP` at boot. */ |
| 19 | 28 | export const BOOT_ACTOR = "boot"; |
| 20 | 29 | |
| @@ -66,7 +75,7 @@ export function signupFromEnv(raw: string | undefined): Result<SignupMode | null | ||
| 66 | 75 | const parsed = parseSignup(s); |
| 67 | 76 | return parsed.ok |
| 68 | 77 | ? 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)}"`); | |
| 70 | 79 | } |
| 71 | 80 | |
| 72 | 81 | 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 | ||
| 12 | 12 | import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts"; |
| 13 | 13 | import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | import { QUOTA_KEYS } from "../../settings/quotas.ts"; |
| 17 | 17 | import type { Ctx } from "../../http/context.ts"; |
| 18 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| @@ -122,7 +122,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 122 | 122 | }), |
| 123 | 123 | ); |
| 124 | 124 | const signupRow = html`<div class="status-row"> |
| 125 | <dt>Signup</dt> | |
| 125 | <dt>New accounts</dt> | |
| 126 | 126 | <dd> |
| 127 | 127 | <form class="row-fields" method="post" action="/admin/signup"> |
| 128 | 128 | <select class="input mono" name="signup" aria-label="Who may sign up"> |
| @@ -132,6 +132,10 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 132 | 132 | </select> |
| 133 | 133 | ${btn("Save", { size: "sm", variant: "primary", label: "Save signup" })} |
| 134 | 134 | </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. |
||
| 135 | 139 | </dd> |
| 136 | 140 | <dd class="status-act"></dd> |
| 137 | 141 | </div>`; |
src/web/pages/people.ts +63 −3
| @@ -7,7 +7,9 @@ | ||
| 7 | 7 | * username wraps anywhere, so the page never scrolls sideways (#9374). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { allowRequest, declineRequest, listWaiting } from "../../actions/requests.ts"; | |
| 10 | 11 | import { setRole } from "../../actions/roles.ts"; |
| 12 | import type { AccountRequest } from "../../core/account_requests.ts"; | |
| 11 | 13 | import { GRANT_MAX_USD } from "../../core/model_grant.ts"; |
| 12 | 14 | import { listPeople, roleLimits, type Person } from "../../core/roles.ts"; |
| 13 | 15 | import { getUserByName, type User } from "../../core/users.ts"; |
| @@ -15,9 +17,9 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t | ||
| 15 | 17 | import type { Ctx } from "../../http/context.ts"; |
| 16 | 18 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 17 | 19 | import { field, readForm } from "../form.ts"; |
| 18 | import { html } from "../html.ts"; | |
| 20 | import { html, type Html } from "../html.ts"; | |
| 19 | 21 | 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"; | |
| 21 | 23 | import { accountSections } from "./origins.ts"; |
| 22 | 24 | |
| 23 | 25 | /** |
| @@ -36,6 +38,60 @@ export function appsMade(ctx: Ctx): Map<string, string[]> { | ||
| 36 | 38 | return made; |
| 37 | 39 | } |
| 38 | 40 | |
| 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 | ||
| 39 | 95 | // GET /settings/people |
| 40 | 96 | export function peoplePage(ctx: Ctx): Response { |
| 41 | 97 | if (!ctx.auth.user) return signInFirst(ctx); |
| @@ -43,6 +99,10 @@ export function peoplePage(ctx: Ctx): Response { | ||
| 43 | 99 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 44 | 100 | const rows = timePhase(ctx.request, "people", () => listPeople(ctx.app.db)); |
| 45 | 101 | 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 | }); | |
| 46 | 106 | const rendering = performance.now(); |
| 47 | 107 | const line = (p: Person) => { |
| 48 | 108 | const role = p.role; |
| @@ -79,7 +139,7 @@ export function peoplePage(ctx: Ctx): Response { | ||
| 79 | 139 | body: html`<div class="page-head"><h1>People</h1></div> |
| 80 | 140 | ${settingsLayout( |
| 81 | 141 | accountSections("people", { admin: true }), |
| 82 | html`<section class="settings-block"> | |
| 142 | html`${waitingList(waiting, "/settings/people")}<section class="settings-block"> | |
| 83 | 143 | <h2>Guests, members and admins</h2> |
| 84 | 144 | <p class="muted">Make a guest a member, with an allowance if you like.</p> |
| 85 | 145 | ${ |
src/web/pages/signup-passkey.ts +5 −3
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 11 | 11 | import { createSession } from "../../core/sessions.ts"; |
| 12 | 12 | import { getUserByName } from "../../core/users.ts"; |
| 13 | 13 | import { isSafeName } from "../../git/guard.ts"; |
| @@ -102,11 +102,13 @@ export async function signupPasskey(ctx: Ctx): Promise<Response> { | ||
| 102 | 102 | if (typeof slot === "number") return tooMany(slot); |
| 103 | 103 | const created = await signUpWithPasskey( |
| 104 | 104 | 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" }, | |
| 107 | 107 | ); |
| 108 | 108 | if (!created.ok) |
| 109 | 109 | 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); | |
| 110 | 112 | const session = createSession(ctx.app.db, created.value.id); |
| 111 | 113 | return json( |
| 112 | 114 | { 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"; | ||
| 18 | 18 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 19 | 19 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts"; |
| 20 | 20 | 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"; | |
| 22 | 24 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 23 | 25 | import { listPins } from "../../core/pins.ts"; |
| 24 | 26 | import { getReposByIds } from "../../core/repos.ts"; |
| @@ -219,6 +221,7 @@ export async function home(ctx: Ctx): Promise<Response> { | ||
| 219 | 221 | interface AuthValues { |
| 220 | 222 | username?: string; |
| 221 | 223 | email?: string; |
| 224 | note?: string; | |
| 222 | 225 | } |
| 223 | 226 | |
| 224 | 227 | interface AuthErrors { |
| @@ -231,6 +234,7 @@ interface AuthErrors { | ||
| 231 | 234 | /** What `/signin` says about new accounts in each signup mode; open says nothing. */ |
| 232 | 235 | const SIGNUP_SENTENCES: Record<SignupMode, string | undefined> = { |
| 233 | 236 | open: undefined, |
| 237 | request: "New accounts here are looked at first: ask for one and someone who runs this forge will answer.", | |
| 234 | 238 | invite: "This forge is invite-only: new accounts need an invite link.", |
| 235 | 239 | closed: "This forge is closed to new accounts.", |
| 236 | 240 | }; |
| @@ -343,6 +347,16 @@ function authForm( | ||
| 343 | 347 | ...(errors.email ? { error: errors.email } : {}), |
| 344 | 348 | }), |
| 345 | 349 | )} |
| 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 | )} | |
| 346 | 360 | ${when(!byPasskey, () => |
| 347 | 361 | formField({ |
| 348 | 362 | label: "Password", |
| @@ -556,6 +570,9 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> { | ||
| 556 | 570 | return response; |
| 557 | 571 | } |
| 558 | 572 | 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)); | |
| 559 | 576 | return page( |
| 560 | 577 | { |
| 561 | 578 | ctx, |
| @@ -574,8 +591,10 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> { | ||
| 574 | 591 | ); |
| 575 | 592 | } |
| 576 | 593 | 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()); | |
| 577 | 596 | return redirect( |
| 578 | safeNext(ctx.url.searchParams.get("next")) ?? "/", | |
| 597 | welcome ? "/signup/welcome" : (safeNext(ctx.url.searchParams.get("next")) ?? "/"), | |
| 579 | 598 | sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url), |
| 580 | 599 | ); |
| 581 | 600 | } |
| @@ -673,8 +692,9 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 673 | 692 | email, |
| 674 | 693 | password: field(form, "password"), |
| 675 | 694 | humanToken: field(form, "cf-turnstile-response"), |
| 695 | note: field(form, "note"), | |
| 676 | 696 | }, |
| 677 | { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request) }, | |
| 697 | { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request), via: "web" }, | |
| 678 | 698 | ); |
| 679 | 699 | if (!created.ok) { |
| 680 | 700 | if (created.error === "signup_closed") return signupClosedPage(ctx); |
| @@ -695,13 +715,15 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 695 | 715 | { |
| 696 | 716 | ctx, |
| 697 | 717 | 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 }), | |
| 699 | 719 | turnstile: siteKey(ctx) !== undefined, |
| 700 | 720 | scriptJs: passkeyJs, |
| 701 | 721 | }, |
| 702 | 722 | 422, |
| 703 | 723 | ); |
| 704 | 724 | } |
| 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"); | |
| 705 | 727 | const session = createSession(ctx.app.db, created.value.id); |
| 706 | 728 | return redirect( |
| 707 | 729 | safeNext(ctx.url.searchParams.get("next")) ?? "/", |
| @@ -709,6 +731,58 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 709 | 731 | ); |
| 710 | 732 | } |
| 711 | 733 | |
| 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 | ||
| 712 | 786 | // POST /theme — persist the theme choice in a cookie. Works as a plain form |
| 713 | 787 | // submit (redirect back to the referring page) and as a background fetch from |
| 714 | 788 | // 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 */ ` | ||
| 131 | 131 | return post('/signup/passkey' + location.search, { |
| 132 | 132 | username: who.username, |
| 133 | 133 | email: who.email, |
| 134 | note: val('note'), | |
| 134 | 135 | humanToken: val('cf-turnstile-response'), |
| 135 | 136 | challenge: o.challenge, |
| 136 | 137 | 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 | ||
| 411 | 411 | "", |
| 412 | 412 | "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.", |
| 413 | 413 | "", |
| 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.`, | |
| 415 | 415 | `- 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.`, |
| 416 | 416 | `- 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`, |
| 417 | 417 | `- 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 { | ||
| 21 | 21 | import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts"; |
| 22 | 22 | import { appsRuntime, type EnvState } from "../../apps/runtime.ts"; |
| 23 | 23 | import { signupClosed } from "../../actions/users.ts"; |
| 24 | import { listWaiting } from "../../actions/requests.ts"; | |
| 25 | import { waitingList } from "../pages/people.ts"; | |
| 24 | 26 | import { getSignup } from "../../settings/signup.ts"; |
| 25 | 27 | import { appsForRepos } from "../../core/apps.ts"; |
| 26 | 28 | import { WEIGHT_MAX, WEIGHT_NAMES } from "../../core/front.ts"; |
| @@ -351,7 +353,14 @@ export async function studioHome( | ||
| 351 | 353 | isNavigation(ctx.request) ? noteVisit(ctx.app.db, user.id, visitedAt) : lastVisit(ctx.app.db, user.id), |
| 352 | 354 | ); |
| 353 | 355 | 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}`; | |
| 355 | 364 | const body = html`<h1 class="sr-only">Home</h1> |
| 356 | 365 | ${tellBox(ctx, user)} |
| 357 | 366 | ${ask} ${address} |
| @@ -372,7 +381,7 @@ export async function studioHome( | ||
| 372 | 381 | body, |
| 373 | 382 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${refreshJs}\n${registerWorkerJs}${ask ? `\n${passkeyJs}` : ""}`, |
| 374 | 383 | }); |
| 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"]); | |
| 376 | 385 | return res; |
| 377 | 386 | } |
| 378 | 387 |
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'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("<b>a game</b>"); | |
| 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 () => { | ||
| 74 | 74 | await h.open(); |
| 75 | 75 | expect(h.text()).toContain("signup: open"); |
| 76 | 76 | // picker, closed, then esc on the plan |
| 77 | await h.press("s", "j", "j", "enter"); | |
| 77 | await h.press("s", "j", "j", "j", "enter"); | |
| 78 | 78 | expect(h.text()).toContain("from open to closed"); |
| 79 | 79 | await h.press("esc"); |
| 80 | 80 | expect(getSignup(forge.db)).toBe("open"); |
| 81 | 81 | // esc on the picker too |
| 82 | 82 | await h.press("s", "esc"); |
| 83 | 83 | 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"); | |
| 85 | 85 | expect(getSignup(forge.db)).toBe("closed"); |
| 86 | 86 | expect(signupClosed({ db: forge.db } as never)).toBe(true); |
| 87 | 87 | expect(h.text()).toContain("signup: closed"); |
| @@ -100,7 +100,7 @@ test("a non-admin and an admin agent are never offered s", async () => { | ||
| 100 | 100 | const h = boot(who); |
| 101 | 101 | await h.open(); |
| 102 | 102 | 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"); | |
| 104 | 104 | expect(getSignup(forge.db)).toBe("open"); |
| 105 | 105 | h.stop(); |
| 106 | 106 | const user = getUserByName(forge.db, who)!; |
new above
offline
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.