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 #10932

Merged opened this in answer to

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

Merged

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

  • Merged. scout/10928-request-access-a-stranger-can-ask-for-an was merged into main as 7615678.

Review

Sign in to review this change.
49 files changed +1144 −59
docs/MCP.md +2 −1
@@ -42,6 +42,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
4242 | Tool | Actions / kinds | Purpose | Bytes |
4343 | --- | --- | --- | ---: |
4444 | `access` | — | Non-browser public-path requests, redacted, newest first (max 500) | 235 |
45 | `account_requests` | — | List who asked for an account and wait; `answer: allow\|decline` answers one | 250 |
4546 | `account_role` | — | Make a person a member or a guest; promoting may grant `usdPerMonth` | 374 |
4647 | `account_suspend` | — | Suspend an account; `suspended: false` lifts | 231 |
4748 | `account_verify` | — | Mark a person's email verified (admin) | 181 |
@@ -193,7 +194,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
193194 | `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 |
194195 | `sever` | — | Unlink a spawned forge from its parent (owner of its `forge` repo only) | 214 |
195196 | `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 |
197 | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 291 |
197198 | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 |
198199 | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 |
199200 | `spawn` | — | Spawn a child forge | 824 |
docs/design/audit.md +1
@@ -130,6 +130,7 @@ 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` · what a stranger whose request waits is told · `/signin` · colours 3 (file).
133134 - `/verify` — `pages/site.ts` · confirm an email address · none · colours 3 (file).
134135 - `/recover` — `pages/passkeys.ts` · get back in without a password · `/settings/passkeys` · none seen.
135136 - `/settings` — `pages/account.ts` · my account · the rest of settings · colour 1.
docs/features/request-access.md added +29
@@ -0,0 +1,29 @@
1 # Request access (#10928)
2
3 `New accounts` has four modes (Admin → Limits, `signup` over MCP and `PATCH /api/v1/admin/settings`):
4
5 | mode | meaning |
6 | --- | --- |
7 | `open` | anyone can make an account and starts at once; a new account is a guest |
8 | `request` | anyone can ask for an account; it waits until an admin allows it |
9 | `invite` | only a person with an invite link can make an account |
10 | `closed` | no new accounts |
11
12 Invite links keep working in every mode but `closed`; in `request` an open invite is a vouch, so its holder starts at once.
13
14 ## A stranger
15 `/signup` in `request` mode asks for a username, a password or passkey, and an optional "What do you want to build?". The account is made *waiting* (`user.waiting_at`): `isActive` is false, so no session, token, key or password opens anything. They land on `/signup/requested`: "Your request is in. Someone who runs this forge will look at it; you can close this page and sign in later to check." Signing in (password or passkey) while waiting shows the wait and starts no session; after Decline it says so politely. After Allow the next sign-in works, and home says "You're in." once.
16
17 `POST /api/v1/users` answers `202 {state:"waiting", username, message}`; it takes `note` and `from` (the forge or agent asking). The terminal's create screen says the same sentence.
18
19 ## The admin
20 - The notifier sends a line for each of the first three waiting requests, then one count line ("N people ask for an account") that moves only every 25 more (`crew/notify.ts`, `requestLines`), linking to the Waiting list.
21 - The terminal's **Waiting** screen (palette, `src/tui/screens/requests.ts`): `a` allows, `d` declines, through the same actions.
22 - The **Waiting** list is on top of Settings → Accounts and in home's "Needs you": name, note, when, from where, with **Allow** and **Decline** one tap each.
23 - API: `GET /api/v1/admin/requests`, `POST /api/v1/admin/requests/:username/allow|decline`; MCP: `account_requests`.
24 - Allow makes the person a member (small quotas, no model grant: the crew spends nothing until an admin gives credits). Decline keeps the record (`account_request`).
25
26 ## Limits
27 At most 200 requests wait (`WAITING_CAP`): past that a stranger meets the closed door, except one holding an open invite, until the admin answers some. Updating a forge that stored `closed` leaves it `closed`; the migration changes no setting.
28
29 `NEWCOMER_QUOTAS` (`settings/quotas.ts`: 3 repositories, 2 apps, 200 MB, 1 preview, 1 run, 1 check) hold an account that was allowed from a request, unless the admin stored a quota of their own, which wins for everyone. Admins, existing members and guests are unchanged.
src/actions/app_limit.ts +3 −3
@@ -7,7 +7,7 @@
77 */
88
99 import type { App } from "../app.ts";
10 import { getQuota } from "../settings/quotas.ts";
10 import { quotaOf } from "../settings/quotas.ts";
1111 import type { User } from "../core/users.ts";
1212 import { err, ok, type Result } from "../kernel/result.ts";
1313
@@ -24,7 +24,7 @@ export function appsOwnedBy(app: App, ownerId: string): number {
2424
2525 /** The refusal, in words: the limit and the count, e.g. "3 of 3 apps". */
2626 export function appLimitWords(app: App, ownerId: string): string {
27 const limit = getQuota(app.db, "apps") ?? 0;
27 const limit = quotaOf(app.db, "apps", ownerId) ?? 0;
2828 return `This account is at its limit: ${Math.min(appsOwnedBy(app, ownerId), limit)} of ${limit} apps. Remove one to make room.`;
2929 }
3030
@@ -35,7 +35,7 @@ export function requireAppRoom(
3535 ownerId: string,
3636 ): Result<void, "app_limit"> {
3737 if (actor.isAdmin) return ok();
38 const limit = getQuota(app.db, "apps");
38 const limit = quotaOf(app.db, "apps", ownerId);
3939 if (limit === null || appsOwnedBy(app, ownerId) < limit) return ok();
4040 return err("app_limit");
4141 }
src/actions/requests.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * Answering a stranger's request for an account, for a forge admin who is a
3 * person (an agent and everyone else is told `not_found`). Every door calls
4 * these: the page's buttons, the API and MCP.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import type { App } from "../app.ts";
9 import { answerRequest, waitingRequests, type AccountRequest } from "../core/requests.ts";
10 import { setMember } from "../core/roles.ts";
11 import { getUserByName, type User } from "../core/users.ts";
12 import { err, ok, type Result } from "../kernel/result.ts";
13
14 export type RequestError = "not_found" | "not_waiting";
15
16 function admin(actor: User | undefined): Result<User, "not_found"> {
17 return actor?.isAdmin === true && actor.kind === "user" ? ok(actor) : err("not_found");
18 }
19
20 export function listRequests(app: App, actor: User | undefined): Result<AccountRequest[], "not_found"> {
21 const who = admin(actor);
22 return who.ok ? ok(waitingRequests(app.db)) : who;
23 }
24
25 function answer(
26 app: App,
27 actor: User | undefined,
28 username: string,
29 state: "allowed" | "declined",
30 ): Result<AccountRequest, RequestError> {
31 const who = admin(actor);
32 if (!who.ok) return who;
33 const done = answerRequest(app.db, username, state, who.value.id, new Date().toISOString());
34 if (done === undefined) return err("not_waiting");
35 // Allowed, they are a member under the newcomer's small quotas and with
36 // no model grant: they can make things, and the crew spends nothing until
37 // an admin gives credits.
38 if (state === "allowed") {
39 const person = getUserByName(app.db, username);
40 if (person !== undefined) setMember(app.db, person.id, true, who.value.id, new Date());
41 }
42 app.bus.append({
43 kind: state === "allowed" ? "account.allowed" : "account.declined",
44 actorId: who.value.id,
45 payload: { username: done.username },
46 });
47 return ok(done);
48 }
49
50 export const allowRequest = (app: App, actor: User | undefined, username: string) =>
51 answer(app, actor, username, "allowed");
52
53 export const declineRequest = (app: App, actor: User | undefined, username: string) =>
54 answer(app, actor, username, "declined");
55
56 /** Past this many waiting, the phone hears one count line, not one per person. */
57 export const LINES_EACH = 3;
58
59 /**
60 * What an admin's phone hears at once: a line per request while a few wait,
61 * then one line with the count (its id moves on every 25 more), linking to
62 * the Waiting list where Allow and Decline are one tap. The notifier sends
63 * each id once (`crew/notify.ts`), so a flood cannot spam the phone.
64 */
65 export function requestLines(
66 db: Database,
67 user: User,
68 ): { id: string; line: string; items: readonly string[]; link: string }[] {
69 if (!user.isAdmin || user.kind !== "user") return [];
outdated

One notifier line per waiting request lets a flood spam the admin's phone. Collapse to a single count line past a few.

70 const link = "/settings/accounts#waiting";
71 const waiting = waitingRequests(db);
72 if (waiting.length > LINES_EACH) {
73 const id = `request:many:${Math.ceil(waiting.length / 25)}`;
74 return [{ id, line: `${waiting.length} people ask for an account`, items: [id], link }];
75 }
76 return waiting.map((r) => {
77 const id = `request:${r.username}`;
78 const note = r.note === "" ? "" : `: ${r.note.slice(0, 120)}`;
79 return { id, line: `${r.username} asks for an account${note}`, items: [id], link };
80 });
81 }
src/actions/users.ts +26 −4
@@ -27,6 +27,7 @@ import {
2727 import { mintViaToken, VIA_TOKEN_MAX_MS } from "../core/via_token.ts";
2828 import { id } from "../kernel/id.ts";
2929 import { inviteSpentBy } from "../core/invites.ts";
30 import { cleanAsk, NOTE_MAX, SOURCE_MAX } from "../core/requests.ts";
3031 import {
3132 changePassword as rehash,
3233 cleanDisplayName,
@@ -52,6 +53,9 @@ 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, and which forge or agent asks (`core/requests.ts`). */
57 note?: string;
58 source?: string;
5559 }
5660
5761 export type SignUpError =
@@ -64,6 +68,18 @@ export type SignUpError =
6468 /** What anyone is told when the forge makes no new accounts. */
6569 export const SIGNUP_CLOSED_MESSAGE = "Signup is closed on this forge.";
6670
71 /** What a stranger whose request is in is told, plainly and with no one's name. */
72 export const REQUEST_IN_MESSAGE =
73 "Your request is in. Someone who runs this forge will look at it; you can close this page and sign in later to check.";
74 /** What a waiting person sees when they sign in before an answer. */
75 export const STILL_WAITING_MESSAGE =
76 "Your request is still waiting. Someone who runs this forge will look at it; you can close this page and sign in later to check.";
77 /** What a person is told once, when they sign in after Allow. */
78 export const YOURE_IN_MESSAGE = "You're in.";
79 /** What a person is told, politely, when the answer was no. */
80 export const DECLINED_MESSAGE =
81 "Thanks for asking. This forge isn't able to open an account for you right now. You can still run a forge of your own, and suggest a change to any public app here without an account.";
82
6783 /**
6884 * Whether no new account may be made now. Read from the store on every call,
6985 * so a change takes effect on the next request. While signup is `invite`, an
@@ -148,6 +164,7 @@ export async function signUp(
148164 origin: "signup",
149165 slot: from.slot,
150166 invite: from.invite,
167 ask: { note: cleanAsk(input.note, NOTE_MAX), source: cleanAsk(input.source, SOURCE_MAX) },
151168 });
152169 if (!created.ok) return created;
153170 app.bus.append({
@@ -155,13 +172,14 @@ export async function signUp(
155172 actorId: created.value.id,
156173 payload: {
157174 via: "signup",
175 ...(created.value.waitingAt === null ? {} : { waiting: true }),
158176 ...(from.address === undefined ? {} : { address: from.address }),
159177 },
160178 });
161179 auditInvite(app, created.value.id);
162180 // The address is proven by the link it is sent; until then nothing it
163 // does may create or cost.
164 if (email !== "") await welcome(app, created.value);
181 // does may create or cost. A waiting account is mailed nothing yet.
182 if (email !== "" && created.value.waitingAt === null) await welcome(app, created.value);
165183 return created;
166184 }
167185
@@ -172,6 +190,8 @@ export interface PasskeySignUpInput {
172190 passkey: Credential;
173191 passkeyName: string;
174192 humanToken?: string;
193 note?: string;
194 source?: string;
175195 }
176196
177197 /**
@@ -202,6 +222,7 @@ export async function signUpWithPasskey(
202222 origin: "passkey",
203223 slot: from.slot,
204224 invite: from.invite,
225 ask: { note: cleanAsk(input.note, NOTE_MAX), source: cleanAsk(input.source, SOURCE_MAX) },
205226 });
206227 if (!created.ok) return created;
207228 const kept = addPasskey(app.db, created.value.id, input.passkeyName, input.passkey);
@@ -214,12 +235,13 @@ export async function signUpWithPasskey(
214235 actorId: created.value.id,
215236 payload: {
216237 via: "signup",
238 ...(created.value.waitingAt === null ? {} : { waiting: true }),
217239 ...(from.address === undefined ? {} : { address: from.address }),
218240 },
219241 });
220242 auditInvite(app, created.value.id);
221243 // As on the password path: no address given, no link to send.
222 if (email !== "") await welcome(app, created.value);
244 if (email !== "" && created.value.waitingAt === null) await welcome(app, created.value);
223245 return created;
224246 }
225247
@@ -402,7 +424,7 @@ export async function changePassword(
402424 password: input.current,
403425 source: input.source,
404426 });
405 if (!verified.ok) return err(verified.error.reason);
427 if (!verified.ok) return err(verified.error.reason === "waiting" ? "bad_credentials" : verified.error.reason);
406428 const changed = await rehash(app.db, user.value.id, input.next);
407429 if (!changed.ok)
408430 return err(
src/contract/openapi/routes/getApiV1AdminRequests.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/requests",
6 summary: "The strangers waiting for an account, oldest first (a forge admin's, read scope; anyone else gets 404); answers { items: [{ username, note, source, state, askedAt, decidedAt }], next_cursor }",
7 auth: true,
8 role: "admin",
9 };
src/contract/openapi/routes/index.ts +3
@@ -47,6 +47,7 @@ export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts";
4747 export { route as getApiV1AdminPinned } from "./getApiV1AdminPinned.ts";
4848 export { route as getApiV1AdminProfile } from "./getApiV1AdminProfile.ts";
4949 export { route as getApiV1AdminQuotas } from "./getApiV1AdminQuotas.ts";
50 export { route as getApiV1AdminRequests } from "./getApiV1AdminRequests.ts";
5051 export { route as getApiV1AdminSettings } from "./getApiV1AdminSettings.ts";
5152 export { route as getApiV1AdminShield } from "./getApiV1AdminShield.ts";
5253 export { route as getApiV1AdminSubscriptions } from "./getApiV1AdminSubscriptions.ts";
@@ -242,6 +243,8 @@ export { route as postApiV1AdminPinnedNameCheck } from "./postApiV1AdminPinnedNa
242243 export { route as postApiV1AdminPinnedNameRollback } from "./postApiV1AdminPinnedNameRollback.ts";
243244 export { route as postApiV1AdminPinnedNameUpdate } from "./postApiV1AdminPinnedNameUpdate.ts";
244245 export { route as postApiV1AdminReleasesShaRollback } from "./postApiV1AdminReleasesShaRollback.ts";
246 export { route as postApiV1AdminRequestsUsernameAllow } from "./postApiV1AdminRequestsUsernameAllow.ts";
247 export { route as postApiV1AdminRequestsUsernameDecline } from "./postApiV1AdminRequestsUsernameDecline.ts";
245248 export { route as postApiV1Agents } from "./postApiV1Agents.ts";
246249 export { route as postApiV1Apps } from "./postApiV1Apps.ts";
247250 export { route as postApiV1AsksBulk } from "./postApiV1AsksBulk.ts";
src/contract/openapi/routes/postApiV1AdminRequestsUsernameAllow.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/requests/{username}/allow",
6 summary: "Allow a waiting request for an account (admin scope); answers the request as it now stands",
7 auth: true,
8 role: "admin",
9 refusals: { not_waiting: "that request was answered already, or never waited (409)" },
10 };
src/contract/openapi/routes/postApiV1AdminRequestsUsernameDecline.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/requests/{username}/decline",
6 summary: "Decline a waiting request for an account (admin scope); answers the request as it now stands",
7 auth: true,
8 role: "admin",
9 refusals: { not_waiting: "that request was answered already, or never waited (409)" },
10 };
src/contract/openapi/routes/postApiV1Users.ts +2 −2
@@ -4,11 +4,11 @@ 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: while the forge takes requests (signup `request`) answers 202 { state: 'waiting', username, message } and the account waits for an admin to allow it; otherwise 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",
88 auth: false,
99 role: "anonymous",
1010 status: 201,
11 body: { username: "string", email: "string", password: "string", invite: "string", turnstile_token: "string" },
11 body: { username: "string", email: "string", password: "string", invite: "string", note: "string", from: "string", turnstile_token: "string" },
1212 refusals: {
1313 rate_limited: "429, too many sign-ups; retry-after says when",
1414 too_large: "413, the body is over the limit for a stranger's",
src/core/accounts.ts +5 −1
@@ -32,6 +32,8 @@ export interface AccountRow {
3232 readonly spentUsd: number;
3333 readonly suspendedAt: string | null;
3434 readonly removedAt: string | null;
35 /** Whether a request for this account waits for an answer (\`core/requests.ts\`). */
36 readonly waiting: boolean;
3537 /** Whether the email is proven (\`core/verify.ts\`); agents always are. */
3638 readonly verified: boolean;
3739 }
@@ -58,6 +60,7 @@ export function listAccounts(db: Database): AccountRow[] {
5860 created_at: string;
5961 suspended_at: string | null;
6062 removed_at: string | null;
63 waiting_at: string | null;
6164 verified_at: string | null;
6265 repos: number;
6366 apps: number;
@@ -67,7 +70,7 @@ export function listAccounts(db: Database): AccountRow[] {
6770 []
6871 >(
6972 `SELECT u.id, u.username, u.display_name, u.is_admin, u.is_agent,
70 u.created_at, u.suspended_at, u.removed_at,
73 u.created_at, u.suspended_at, u.removed_at, u.waiting_at,
7174 (SELECT c.verified_at FROM email_check c WHERE c.user_id = u.id) AS verified_at,
7275 (SELECT COUNT(*) FROM repo r WHERE r.owner_id = u.id) AS repos,
7376 (SELECT COUNT(*) FROM app a JOIN repo r ON r.id = a.repo_id
@@ -97,6 +100,7 @@ export function listAccounts(db: Database): AccountRow[] {
97100 spentUsd: Math.round((r.spent ?? 0) * 100) / 100,
98101 suspendedAt: r.suspended_at,
99102 removedAt: r.removed_at,
103 waiting: r.waiting_at !== null && r.removed_at === null,
100104 verified: r.is_agent === 1 || r.verified_at !== null,
101105 };
102106 });
src/core/arg_words.ts +1 −1
@@ -20,7 +20,7 @@ export const PIN_OP_WORDS = "op must be pin, unpin or move";
2020 export const ORDER_WORDS = "order is oldest or newest";
2121
2222 /** The forge's `signup` setting: `open`, `invite` or `closed`. */
23 export const SIGNUP_WORDS = "signup is open, invite or closed";
23 export const SIGNUP_WORDS = "signup is open, request, invite or closed";
2424
2525 /** A repository's `visibility`: `public` or `private`. */
2626 export const VISIBILITY_WORDS = "visibility is public or private";
src/core/event_audience.ts +2
@@ -133,6 +133,8 @@ const READER_KINDS: ReadonlySet<string> = new Set([
133133 "issue.teams",
134134 "account.role_set",
135135 "account.email_verified",
136 "account.allowed",
137 "account.declined",
136138 "account.recovered",
137139 "limb.replan",
138140 "limb.replan_taken_back",
src/core/migration-pins/20261110160000-account-requests.pin added +1
@@ -0,0 +1,1 @@
1 714cb6ce8d8a9955d3f1a6fdc95fb4854c560e3c51a4d8e833693073cfd1d639
src/core/migrations/20261110160000-account-requests.ts added +18
@@ -0,0 +1,18 @@
1 // A stranger asks for an account and waits (#10928). `user.waiting_at` is set
2 // while the account may not act (every credential asks `isActive`); the
3 // request keeps the note, where it came from and the admin's answer. A forge
4 // that stored `closed` stays closed: this changes no stored setting.
5 export default `
6 ALTER TABLE user ADD COLUMN waiting_at TEXT;
7 CREATE TABLE account_request (
8 user_id TEXT PRIMARY KEY REFERENCES user(id) ON DELETE CASCADE,
9 note TEXT NOT NULL DEFAULT '',
10 source TEXT NOT NULL DEFAULT '',
11 state TEXT NOT NULL CHECK (state IN ('waiting', 'allowed', 'declined')),
12 asked_at TEXT NOT NULL,
13 decided_at TEXT,
14 decided_by TEXT,
15 told_at TEXT
16 );
17 CREATE INDEX account_request_state ON account_request(state, asked_at);
18 `;
src/core/requests.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * A stranger's request for an account (#10928): the note they left, where
3 * the ask came from, and the admin's answer. The account itself waits
4 * (`user.waiting_at`, which `isActive` reads); Allow clears that, Decline
5 * keeps the record and the account stays unable to act. Plain data in and
6 * out: who may answer is `actions/requests.ts`'s to say.
7 */
8
9 import type { Database } from "bun:sqlite";
10
11 export type RequestState = "waiting" | "allowed" | "declined";
12
13 export interface AccountRequest {
14 readonly username: string;
15 readonly note: string;
16 /** Where the ask came from: a forge or an agent's name, or empty for the web. */
17 readonly source: string;
18 readonly state: RequestState;
19 readonly askedAt: string;
20 readonly decidedAt: string | null;
21 }
22
23 /** Requests that may wait at once: past this, a stranger meets the closed door until the admin answers some. */
24 export const WAITING_CAP = 200;
25 export const NOTE_MAX = 500;
26 export const SOURCE_MAX = 80;
27
28 /** A note or source from untrusted input: one trimmed line of text, capped, no control characters. */
29 export function cleanAsk(raw: unknown, max: number): string {
30 if (typeof raw !== "string") return "";
31 // eslint-disable-next-line no-control-regex
32 return raw.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, max);
33 }
34
35 interface Row {
36 username: string;
37 note: string;
38 source: string;
39 state: RequestState;
40 asked_at: string;
41 decided_at: string | null;
42 }
43
44 const SELECT = `SELECT u.username, r.note, r.source, r.state, r.asked_at, r.decided_at
45 FROM account_request r JOIN user u ON u.id = r.user_id`;
46
47 const toRequest = (r: Row): AccountRequest => ({
48 username: r.username,
49 note: r.note,
50 source: r.source,
51 state: r.state,
52 askedAt: r.asked_at,
53 decidedAt: r.decided_at,
54 });
55
56 /** Who waits, oldest first: the admin answers in the order they asked. */
57 export function waitingRequests(db: Database): AccountRequest[] {
58 return db
59 .query<Row, []>(`${SELECT} WHERE r.state = 'waiting' AND u.removed_at IS NULL ORDER BY r.asked_at, u.username`)
60 .all()
61 .map(toRequest);
62 }
63
64 export function waitingCount(db: Database): number {
65 return (
66 db
67 .query<{ n: number }, []>("SELECT count(*) AS n FROM account_request WHERE state = 'waiting'")
68 .get()?.n ?? 0
69 );
70 }
71
72 export function requestOf(db: Database, userId: string): AccountRequest | undefined {
73 const row = db.query<Row, [string]>(`${SELECT} WHERE r.user_id = ?1`).get(userId);
74 return row === null ? undefined : toRequest(row);
75 }
76
77 /**
78 * Answer a waiting request: Allow clears the wait, Decline keeps it. Only a
79 * request still waiting can be answered, so two taps answer once. Returns
80 * the request as it now stands, or undefined when none was waiting.
81 */
82 export function answerRequest(
83 db: Database,
84 username: string,
85 state: "allowed" | "declined",
86 by: string,
87 at: string,
88 ): AccountRequest | undefined {
89 return db.transaction((): AccountRequest | undefined => {
90 const found = db
91 .query<{ user_id: string }, [string]>(
92 `SELECT r.user_id FROM account_request r JOIN user u ON u.id = r.user_id
93 WHERE u.username = ?1 COLLATE NOCASE AND r.state = 'waiting'`,
94 )
95 .get(username);
96 if (found === null) return undefined;
97 db.query("UPDATE account_request SET state = ?1, decided_at = ?2, decided_by = ?3 WHERE user_id = ?4").run(
98 state,
99 at,
100 by,
101 found.user_id,
102 );
103 if (state === "allowed") db.query("UPDATE user SET waiting_at = NULL WHERE id = ?1").run(found.user_id);
104 return requestOf(db, found.user_id);
105 })();
106 }
107
108 /**
109 * Whether this account has an answer it has not been told yet; telling it
110 * (`markTold`) is once, so "You're in" is said on one sign-in.
111 */
112 export function untoldAnswer(db: Database, userId: string): RequestState | undefined {
113 const row = db
114 .query<{ state: RequestState }, [string]>(
115 "SELECT state FROM account_request WHERE user_id = ?1 AND state <> 'waiting' AND told_at IS NULL",
116 )
117 .get(userId);
118 return row?.state;
119 }
120
121 export function markTold(db: Database, userId: string, at: string): void {
122 db.query("UPDATE account_request SET told_at = ?1 WHERE user_id = ?2 AND told_at IS NULL").run(at, userId);
123 }
src/core/storage.ts +6 −6
@@ -17,7 +17,7 @@ import { appsRoot } from "../apps/data.ts";
1717 import { dirBytes } from "../apps/bytes.ts";
1818 import { repoDir } from "../git/repos.ts";
1919 import { limitValue } from "../settings/limits.ts";
20 import { getQuota } from "../settings/quotas.ts";
20 import { getQuota, quotaOf } from "../settings/quotas.ts";
2121 import { contributionForksOf, isContributionFork } from "./contribute.ts";
2222 import type { Repo } from "./repos.ts";
2323 import type { User } from "./users.ts";
@@ -190,8 +190,8 @@ export function formatBytes(bytes: number): string {
190190 }
191191
192192 /** The storage limit in bytes, or none. */
193 export function storageLimitBytes(app: App): number | undefined {
194 const mb = getQuota(app.db, "storage");
193 export function storageLimitBytes(app: App, ownerId?: string): number | undefined {
194 const mb = ownerId === undefined ? getQuota(app.db, "storage") : quotaOf(app.db, "storage", ownerId);
195195 return mb === null ? undefined : mb * BYTES_PER_MB;
196196 }
197197
@@ -217,7 +217,7 @@ export async function storageRefusal(
217217 const guest = await contributionRefusal(app, ownerId, repo, user);
218218 if (guest !== undefined) return guest;
219219 }
220 const limit = storageLimitBytes(app);
220 const limit = storageLimitBytes(app, ownerId);
221221 if (limit === undefined || storageExempt(user)) return undefined;
222222 let used = await accountBytes(app, ownerId);
223223 // At the limit, look again: a repository deleted or data cleared since the
@@ -240,7 +240,7 @@ export const storageRefusedText = (used: string): string =>
240240
241241 /** The sentence for a refusal just given, from the figure it was given on. */
242242 export function storageSentence(app: App, ownerId: string): string | undefined {
243 const limit = storageLimitBytes(app);
243 const limit = storageLimitBytes(app, ownerId);
244244 const used = cachedBytes(app, ownerId);
245245 return limit === undefined || used === undefined || used < limit
246246 ? undefined
@@ -259,7 +259,7 @@ export async function pushCap(
259259 repo?: Repo,
260260 ): Promise<Record<string, string>> {
261261 if (storageExempt(user)) return {};
262 const limit = storageLimitBytes(app);
262 const limit = storageLimitBytes(app, ownerId);
263263 const guest =
264264 repo !== undefined && isContributionFork(app.db, repo.id)
265265 ? (await contributionRoom(app, ownerId, repo)).room
src/core/users.ts +53 −9
@@ -11,6 +11,7 @@ import { reservedName } from "./reserved.ts";
1111 import { id } from "../kernel/id.ts";
1212 import { err, ok, type Result } from "../kernel/result.ts";
1313 import { admitSignin } from "./signin-window.ts";
14 import { waitingCount, WAITING_CAP } from "./requests.ts";
1415 import { getSignup } from "../settings/signup.ts";
1516 import { inviteOpen, redeemInvite, reopenInvite } from "./invites.ts";
1617 import type { SignupSlot } from "../actions/signup-window.ts";
@@ -38,6 +39,12 @@ export interface User {
3839 readonly suspendedAt: string | null;
3940 /** When the owner removed it. The row stays so the name stays taken. */
4041 readonly removedAt: string | null;
42 /**
43 * Set while a stranger's request for this account is unanswered, and kept
44 * once it is declined; cleared by Allow (`core/requests.ts`). Such an
45 * account can do nothing: every credential asks `isActive`.
46 */
47 readonly waitingAt: string | null;
4148 }
4249
4350 export type CommitEmail = "noreply" | "account";
@@ -55,6 +62,7 @@ interface UserRow {
5562 created_at: string;
5663 suspended_at: string | null;
5764 removed_at: string | null;
65 waiting_at: string | null;
5866 }
5967
6068 function toUser(row: UserRow): User {
@@ -71,6 +79,7 @@ function toUser(row: UserRow): User {
7179 createdAt: row.created_at,
7280 suspendedAt: row.suspended_at,
7381 removedAt: row.removed_at,
82 waitingAt: row.waiting_at ?? null,
7483 };
7584 }
7685
@@ -79,7 +88,7 @@ function toUser(row: UserRow): User {
7988 * password, edge sign-in, SSH key — asks this before it names a person.
8089 */
8190 export const isActive = (user: User): boolean =>
82 user.suspendedAt === null && user.removedAt === null;
91 user.suspendedAt === null && user.removedAt === null && user.waitingAt === null;
8392
8493 /**
8594 * Whether `viewer` finds no account at `user`'s name: a suspended or removed
@@ -136,7 +145,13 @@ export type AccountOrigin =
136145 * `invite` is the token of an invite link (`core/invites.ts`): while
137146 * signup is `invite` it opens the door once, spent with the account.
138147 */
139 | { origin: "signup" | "passkey"; slot: SignupSlot; invite?: string | undefined }
148 | {
149 origin: "signup" | "passkey";
150 slot: SignupSlot;
151 invite?: string | undefined;
152 /** What they say they want to build, and where the ask came from (`core/requests.ts`). */
153 ask?: { note: string; source: string } | undefined;
154 }
140155 | { origin: "edge" }
141156 | { origin: Exclude<ExemptOrigin, AdminOrigin> }
142157 | ({ origin: AdminOrigin } & AdminFields);
@@ -162,13 +177,19 @@ export type CreateUserInput = CreateUserFields & AccountOrigin;
162177 * (`inviteNeeded` says when it must be spent).
163178 */
164179 export function newAccountsClosed(db: Database, invite?: string): boolean {
180 // In `request` the door shuts once the Waiting list is full, for anyone an open invite does not vouch for.
181 if (getSignup(db) === "request") return waitingCount(db) >= WAITING_CAP && !(invite !== undefined && inviteOpen(db, invite));
165182 if (!inviteNeeded(db)) return false;
166183 return !(getSignup(db) === "invite" && invite !== undefined && inviteOpen(db, invite));
167184 }
168185
169 /** Whether a stranger needs an invite (or cannot come in at all) now. */
186 /**
187 * Whether a stranger needs an invite (or cannot come in at all) now. In
188 * `request` anyone may ask, and waits unless an open invite vouches for them.
189 */
170190 function inviteNeeded(db: Database): boolean {
171 return getSignup(db) !== "open";
191 const mode = getSignup(db);
192 return mode === "invite" || mode === "closed";
172193 }
173194
174195 interface CreateUserFields {
@@ -230,9 +251,9 @@ export function discardFreshUser(db: Database, userId: string): void {
230251 */
231252 type Admission =
232253 | { readonly admitted: false }
233 | { readonly admitted: true; readonly spend: string | undefined };
254 | { readonly admitted: true; readonly spend: string | undefined; readonly request: "waiting" | "none" };
234255
235 const ADMITTED: Admission = { admitted: true, spend: undefined };
256 const ADMITTED: Admission = { admitted: true, spend: undefined, request: "none" };
236257 const REFUSED: Admission = { admitted: false };
237258
outdated

HIGH: in request mode every stranger gets a permanent row, with no cap on how many wait. Count waiting requests here and refuse above a cap. Test it.

238259 function admission(db: Database, input: AccountOrigin): Admission {
@@ -240,10 +261,16 @@ function admission(db: Database, input: AccountOrigin): Admission {
240261 case "signup":
241262 case "passkey":
242263 if (input.slot.spent) throw new Error("a sign-up slot makes one account");
264 if (getSignup(db) === "request") {
265 // An open invite vouches for its holder; anyone else waits to be allowed.
266 if (input.invite !== undefined && inviteOpen(db, input.invite))
267 return { admitted: true, spend: input.invite, request: "none" };
268 return waitingCount(db) >= WAITING_CAP ? REFUSED : { admitted: true, spend: undefined, request: "waiting" };
269 }
243270 if (!inviteNeeded(db)) return ADMITTED;
244271 return newAccountsClosed(db, input.invite) || input.invite === undefined
245272 ? REFUSED
246 : { admitted: true, spend: input.invite };
273 : { admitted: true, spend: input.invite, request: "none" };
247274 case "edge":
248275 return newAccountsClosed(db) ? REFUSED : ADMITTED;
249276 case "claim":
@@ -343,6 +370,15 @@ export async function createUser(
343370 admin.adminIfFirst ? 1 : 0,
344371 agent ? 1 : 0,
345372 );
373 // A stranger's account keeps its ask, in the same write, waiting until allowed.
374 if (admitted.request === "waiting") {
375 const at = new Date().toISOString();
376 const ask = "ask" in input ? input.ask : undefined;
377 db.query("UPDATE user SET waiting_at = ?1 WHERE id = ?2").run(at, userId);
378 db.query(
379 "INSERT INTO account_request (user_id, note, source, state, asked_at) VALUES (?1, ?2, ?3, 'waiting', ?4)",
380 ).run(userId, ask?.note ?? "", ask?.source ?? "", at);
381 }
346382 return true;
347383 });
348384 try {
@@ -480,7 +516,9 @@ export function ensureEdgeUser(db: Database, login: string): User | undefined {
480516 /** Why a password was not accepted: wrong, or tried too often to check. */
481517 export type PasswordRefusal =
482518 | { readonly reason: "bad_credentials" }
483 | { readonly reason: "throttled"; readonly retryAfter: number };
519 | { readonly reason: "throttled"; readonly retryAfter: number }
520 /** The password is right, and a request for the account still has no yes (\`core/requests.ts\`): no credential. */
521 | { readonly reason: "waiting"; readonly username: string };
484522
485523 /** One password attempt: who it names, the secret, and the peer that sent it. */
486524 export interface PasswordAttempt {
@@ -515,6 +553,10 @@ export async function verifyPasswordThrottled(
515553 admission.succeeded();
516554 return err({ reason: "throttled", retryAfter: GATE_RETRY_AFTER });
517555 }
556 if (!checked.ok && checked.error === "waiting") {
557 admission.succeeded();
558 return err({ reason: "waiting", username: attempt.username });
559 }
518560 if (!checked.ok) return err({ reason: "bad_credentials" });
519561 admission.succeeded();
520562 return checked;
@@ -538,7 +580,7 @@ async function checkPassword(
538580 db: Database,
539581 username: string,
540582 password: string,
541 ): Promise<Result<User, "bad_credentials">> {
583 ): Promise<Result<User, "bad_credentials" | "waiting">> {
542584 const row = db
543585 .query<
544586 UserRow,
@@ -555,6 +597,8 @@ async function checkPassword(
555597 if (!valid || !row || row.kind === "org" || row.is_agent === 1)
556598 return err("bad_credentials");
557599 const user = toUser(row);
600 // Only the one who knows the password is told their request waits; no credential comes of it.
601 if (user.waitingAt !== null && user.suspendedAt === null && user.removedAt === null) return err("waiting");
558602 return isActive(user) ? ok(user) : err("bad_credentials");
559603 }
560604
src/crew/notify.ts +2
@@ -22,6 +22,7 @@
2222
2323 import { blockedLines } from "../actions/egress_refused.ts";
2424 import { newAccountLines } from "../actions/accounts.ts";
25 import { requestLines } from "../actions/requests.ts";
2526 import { mailCeilingLines } from "../actions/mail.ts";
2627 import { forgetOnboarding, onboardedLines } from "../apps/onboarding.ts";
2728 import type { Database } from "bun:sqlite";
@@ -501,6 +502,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
501502 // to itself.
502503 const asks: { line: string; items: readonly string[]; link?: string }[] = [
503504 ...askLinesFor(app.db, user).map((a) => ({ line: a.line, items: a.items })),
505 ...requestLines(app.db, user),
504506 ...newAccountLines(app.db, user, now()),
505507 ...landedLines(app.db, user, now()),
506508 ...mailCeilingLines(app, user, now()),
src/http/api.ts +8 −2
@@ -127,6 +127,7 @@ import {
127127 PASSKEY_REQUIRED_MESSAGE,
128128 signUp,
129129 SIGNUP_CLOSED_MESSAGE,
130 REQUEST_IN_MESSAGE,
130131 } from "../actions/users.ts";
131132 import { OFFSET_LIMIT_MAX, offsetPage, offsetQuery } from "./offset_page.ts";
132133 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor, validEventsKind } from "../core/events_page.ts";
@@ -193,7 +194,7 @@ import { isPinned } from "../core/pins.ts";
193194 import { diffCommit } from "../git/diff.ts";
194195 import { runGit } from "../git/env.ts";
195196 import { appLimitWords, appsOwnedBy } from "../actions/app_limit.ts";
196 import { getQuota } from "../settings/quotas.ts";
197 import { getQuota, quotaOf } from "../settings/quotas.ts";
197198 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
198199 import { compareBranches, listBranchRows } from "../git/branches.ts";
199200 import {
@@ -1099,7 +1100,7 @@ export function positiveInt(raw: string | undefined): number | undefined {
10991100 /** 403 naming the limit and the count, for an account at its app limit. */
11001101 export function appLimitResponse(ctx: Ctx, ownerName: string): Response {
11011102 const owner = getUserByName(ctx.app.db, ownerName);
1102 const limit = getQuota(ctx.app.db, "apps");
1103 const limit = owner ? quotaOf(ctx.app.db, "apps", owner.id) : getQuota(ctx.app.db, "apps");
11031104 return json(
11041105 {
11051106 error: "app_limit",
@@ -1192,6 +1193,9 @@ export function apiRoutes(router: Router<Handler>): void {
11921193 email: str(input.email),
11931194 password: str(input.password),
11941195 humanToken: str(input.turnstile_token),
1196 // Where the ask comes from (a forge or an agent) and what they want to build.
1197 note: str(input.note),
1198 source: str(input.from),
11951199 },
11961200 {
11971201 slot,
@@ -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 // Signup is `request`: the account waits for an admin, and acts as nothing yet.
1214 if (created.value.waitingAt !== null) return json({ state: "waiting", username: created.value.username, message: REQUEST_IN_MESSAGE }, 202);
12091215 return json(publicUser(created.value), 201);
12101216 });
12111217
src/http/api/admin.ts +19
@@ -21,6 +21,7 @@ import { setAccountCeilings, setAccountGrant } from "../../actions/model_grant.t
2121 import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts";
2222 import { TARGETS } from "../../cloud/targets.ts";
2323 import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
24 import { allowRequest, declineRequest, listRequests } from "../../actions/requests.ts";
2425 import { setRole } from "../../actions/roles.ts";
2526 import { SETTING_WORDS } from "../../actions/setting_words.ts";
2627 import { changeForgeSource, changeSignup, readForgeSource, readSignup } from "../../actions/signup.ts";
@@ -435,6 +436,24 @@ export function adminRoutes(router: Router<Handler>): void {
435436 : notFound();
436437 });
437438
439 // Strangers waiting for an account (#10928), and the answer to each.
440 router.add("GET", "/api/v1/admin/requests", (ctx) => {
441 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
442 const denied = accountAdmin(ctx, "ro");
443 if (denied) return denied;
444 const listed = listRequests(ctx.app, ctx.auth.user);
445 return listed.ok ? json({ items: listed.value, next_cursor: null }) : notFound();
446 });
447 const answerRequest = (act: "allow" | "decline"): Handler => (ctx) => {
448 const denied = accountAdmin(ctx, "admin");
449 if (denied) return denied;
450 const done = (act === "allow" ? allowRequest : declineRequest)(ctx.app, ctx.auth.user, ctx.params.username!);
451 if (done.ok) return json(done.value);
452 return done.error === "not_waiting" ? apiError("not_waiting", 409, "that request is not waiting") : notFound();
453 };
454 router.add("POST", "/api/v1/admin/requests/:username/allow", answerRequest("allow"));
455 router.add("POST", "/api/v1/admin/requests/:username/decline", answerRequest("decline"));
456
438457 // Every person and their role.
439458 router.add("GET", "/api/v1/admin/people", (ctx) => {
440459 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
src/http/mcp/tools/account_requests.ts added +28
@@ -0,0 +1,28 @@
1 import { allowRequest, declineRequest, listRequests } from "../../../actions/requests.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { good, bad, type Tool, mayBoards } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "account_requests",
7 doc: "`{ username?, answer? }`: the strangers waiting for an account (name, note, when, from where); with `username` and `answer` `allow` or `decline`, answer one; a forge admin's, and answering needs an admin token",
8 description: "List who asked for an account and wait; `answer: allow|decline` answers one.",
9 inputSchema: {
10 type: "object",
11 properties: { username: { type: "string" }, answer: { type: "string", enum: ["allow", "decline"] } },
12 },
13 writes: true,
14 run(ctx, user, args) {
15 if (!mayBoards(user)) return bad("not_found", "no such tool here");
16 const { username, answer } = args;
17 if (username === undefined && answer === undefined) {
18 const listed = listRequests(ctx.app, user);
19 return listed.ok ? good({ waiting: listed.value }) : bad("not_found", "no such tool here");
20 }
21 if (typeof username !== "string" || (answer !== "allow" && answer !== "decline"))
22 return bad("invalid", "send a username and answer: allow or decline");
23 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "answering a request needs an admin token");
24 const done = (answer === "allow" ? allowRequest : declineRequest)(ctx.app, user, username);
25 if (done.ok) return good({ username: done.value.username, state: done.value.state });
26 return done.error === "not_waiting" ? bad("not_waiting", "that request is not waiting") : bad("not_found", "no such person");
27 },
28 };
src/http/mcp/tools/index.ts +1
@@ -1,6 +1,7 @@
11 // Generated by `bun scripts/mcp-tools.ts` from the files beside it; do not edit.
22 // One line per tool, sorted: a new tool is a new file and one new line here.
33 export { tool as access } from "./access.ts";
4 export { tool as account_requests } from "./account_requests.ts";
45 export { tool as account_role } from "./account_role.ts";
56 export { tool as account_suspend } from "./account_suspend.ts";
67 export { tool as account_verify } from "./account_verify.ts";
src/http/server.ts +2
@@ -140,6 +140,7 @@ import {
140140 signinPage,
141141 signinSubmit,
142142 signupPage,
143 requestedPage,
143144 signupSubmit,
144145 verifyPage,
145146 themeSubmit,
@@ -860,6 +861,7 @@ export function buildRouter(): Router<Handler> {
860861 router.add("GET", "/signin/parent", parentStart);
861862 router.add("GET", "/signin/parent/callback", parentCallback);
862863 router.add("GET", "/signup", signupPage);
864 router.add("GET", "/signup/requested", requestedPage);
863865 router.add("GET", "/verify", verifyPage);
864866 router.add("POST", "/signup", signupSubmit);
865867 router.add("POST", "/logout", logoutSubmit);
src/http/settings_routes.ts +3
@@ -25,6 +25,7 @@ import { securityControlsPage } from "../web/pages/security_controls.ts";
2525 import { securityPage, securityShieldSet } from "../web/pages/security.ts";
2626 import { trafficPage } from "../web/pages/traffic.ts";
2727 import { sourcesPage } from "../web/pages/sources.ts";
28 import { requestAct } from "../web/requests_card.ts";
2829 import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts";
2930 import {
3031 accountAct,
@@ -239,6 +240,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
239240 router.add("POST", "/settings/backups/mirror/clear", backupsMirrorClear);
240241 router.add("POST", "/settings/backups/mirror/now", backupsMirrorNow);
241242 router.add("GET", "/settings/accounts", accountsPage);
243 router.add("POST", "/settings/accounts/:username/allow", requestAct("allow"));
244 router.add("POST", "/settings/accounts/:username/decline", requestAct("decline"));
242245 router.add("GET", "/settings/people", peoplePage);
243246 router.add("POST", "/settings/people/:username/role", roleAct);
244247 router.add("GET", "/settings/model", modelPage);
src/settings/quotas.ts +25 −2
@@ -27,6 +27,29 @@ export function isQuotaKey(key: string): key is QuotaKey {
2727 return (QUOTA_KEYS as readonly string[]).includes(key);
2828 }
2929
30 /**
31 * What an account that came through a request (`core/requests.ts`) is held
32 * to when the admin has set no limit of their own: small, so that letting a
33 * stranger in costs little. Admins, existing members and guests who never
34 * asked are not in it; a stored quota is the admin's word and wins.
35 */
36 export const NEWCOMER_QUOTAS: Quotas = {
37 repositories: 3,
38 apps: 2,
39 storage: 200,
40 previews: 1,
41 runs: 1,
42 checks: 1,
43 };
44
45 /** The limit on `key` for the account `ownerId`: the stored one, else the newcomer's, else none. */
46 export function quotaOf(db: Database, key: QuotaKey, ownerId: string): number | null {
47 const stored = getQuota(db, key);
48 if (stored !== null) return stored;
49 const asked = db.query("SELECT 1 FROM account_request WHERE user_id = ?1 AND state = 'allowed'").get(ownerId);
50 return asked === null ? null : NEWCOMER_QUOTAS[key];
51 }
52
3053 const storeKey = (key: QuotaKey): string => `quota.${key}`;
3154
3255 /** A limit from untrusted input: a positive integer, or empty/null for none. */
@@ -75,7 +98,7 @@ export function repositoriesOwned(db: Database, ownerId: string): number {
7598
7699 /** The sentence a refused creation says, or nothing when there is room. */
77100 export function repositoryLimitSentence(db: Database, ownerId: string): string | undefined {
78 const limit = getQuota(db, "repositories");
101 const limit = quotaOf(db, "repositories", ownerId);
79102 if (limit === null) return undefined;
80103 const owned = repositoriesOwned(db, ownerId);
81104 return owned >= limit ? `${owned} of ${limit} repositories` : undefined;
@@ -102,7 +125,7 @@ export function runningLimitSentence(
102125 ownerId: string,
103126 held: () => number,
104127 ): string | undefined {
105 const limit = getQuota(db, key);
128 const limit = quotaOf(db, key, ownerId);
106129 if (limit === null || exempt(db, ownerId)) return undefined;
107130 const n = held();
108131 return n >= limit ? `${n} of ${limit} ${noun}` : undefined;
src/settings/signup.ts +11 −3
@@ -1,5 +1,5 @@
11 /**
2 * Who may make an account: `open`, `invite` or `closed`. Stored in
2 * Who may make an account: `open`, `request`, `invite` or `closed`. Stored in
33 * `forge_setting` under `signup`, and every change is kept in
44 * `forge_setting_change` (who, when, from what to what). Default `open` when nothing is stored; a new forge stores `invite` (`seedSignup`).
55 * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask
@@ -11,7 +11,15 @@ import type { Database } from "bun:sqlite";
1111 import { err, ok, type Result } from "../kernel/result.ts";
1212 import { writeSetting } from "./limits.ts";
1313
14 export const SIGNUP_MODES = ["open", "invite", "closed"] as const;
14 export const SIGNUP_MODES = ["open", "request", "invite", "closed"] as const;
15
16 /** What each mode means, in one line: shown beside the setting and read by the MCP. */
17 export const SIGNUP_MEANS: Record<SignupMode, string> = {
18 open: "anyone can make an account and starts at once, under small limits",
19 request: "anyone can ask for an account; it waits until an admin allows it",
20 invite: "only a person with an invite link can make an account",
21 closed: "no new accounts",
22 };
1523 export type SignupMode = (typeof SIGNUP_MODES)[number];
1624 export const SIGNUP_DEFAULT: SignupMode = "open";
1725
@@ -66,7 +74,7 @@ export function signupFromEnv(raw: string | undefined): Result<SignupMode | null
6674 const parsed = parseSignup(s);
6775 return parsed.ok
6876 ? parsed
69 : err(`FORGE_SIGNUP: open, invite or closed, not "${s.slice(0, 20)}"`);
77 : err(`FORGE_SIGNUP: open, request, invite or closed, not "${s.slice(0, 20)}"`);
7078 }
7179
7280 export interface SignupChange {
src/tui/screens/entries.ts +2
@@ -61,6 +61,7 @@ import { entry as people } from "./people.ts";
6161 import { entry as pinned } from "./pinned.ts";
6262 import { entry as profile } from "./profile.ts";
6363 import { entry as quotas } from "./quotas.ts";
64 import { entry as requests } from "./requests.ts";
6465 import { entry as resilience } from "./resilience.ts";
6566 import { entry as scorecard } from "./scorecard.ts";
6667 import { entry as weekcard } from "./weekcard.ts";
@@ -135,6 +136,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
135136 agents,
136137 archived,
137138 invites,
139 requests,
138140 bulk,
139141 subscriptions,
140142 cloud,
src/tui/screens/requests.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * The Waiting list in the terminal, a forge admin's (#10928): strangers who
3 * asked for an account, oldest first, as the Waiting card on /settings/accounts
4 * has them. `a` allows the selected one and `d` declines it, both through
5 * `actions/requests.ts`, so the gate is theirs (an admin who is a person;
6 * anyone else sees nothing) and the write reaches web viewers on the bus.
7 */
8
9 import { allowRequest, declineRequest, listRequests } from "../../actions/requests.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import { style } from "../term/style.ts";
13 import { truncate } from "../term/width.ts";
14 import type { Session } from "../session.ts";
15 import { fill, len, split } from "../ui/layout.ts";
16 import { ListState, drawList, type ListRow } from "./common.ts";
17 import type { PaletteEntry } from "./entries.ts";
18
19 export function requestsScreen(f: Session, ctx: Ctx): Screen {
20 const state = new ListState();
21 let note = "";
22 const read = () => listRequests(f.app, f.user);
23 const items = () => {
24 const r = read();
25 return r.ok ? r.value : [];
26 };
27
28 const rows = (): ListRow[] =>
29 items().map((r) => ({
30 title: r.username,
31 secondary: r.note === "" ? "no note" : r.note,
32 meta: `asked ${r.askedAt.slice(0, 16)} from ${r.source === "" ? "the web" : r.source}`,
33 }));
34
35 const answer = (act: "allow" | "decline"): boolean => {
36 const row = items()[state.index];
37 if (row === undefined) return false;
38 const done = (act === "allow" ? allowRequest : declineRequest)(f.app, f.user, row.username);
39 note = done.ok
40 ? `${row.username} ${act === "allow" ? "is in" : "was declined, politely"}.`
41 : `${row.username} is not waiting any more.`;
42 ctx.invalidate();
43 return true;
44 };
45
46 const screen: Screen = {
47 title: "Waiting",
48 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "allow"), binding(["d"], "decline")],
49 render(area, s) {
50 const c = ctx.theme.c;
51 const r = read();
52 const [head, body] = split(area, "v", [len(1), fill(1)]);
53 const text =
54 note !== ""
55 ? note
56 : !r.ok
57 ? "The Waiting list is a forge admin's."
58 : r.value.length === 0
59 ? "Nobody is waiting."
60 : "a allows the selected request, d declines it.";
61 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
62 drawList(s, body!, c, state, rows());
63 },
64 key(e) {
65 note = "";
66 if (state.key(e, items().length)) return true;
67 if (e.ctrl || e.alt || !read().ok) return false;
68 if (e.key === "a") return answer("allow");
69 if (e.key === "d") return answer("decline");
70 return false;
71 },
72 };
73 return screen;
74 }
75
76 /** Who asked for an account and waits: a forge admin who is a person. */
77 export const entry: PaletteEntry = {
78 id: "requests",
79 title: "Waiting",
80 hint: "strangers who asked for an account: allow or decline",
81 after: ["invites", "teams", "limits", "quotas", "people", "can"],
82 offered: (session) => session.user?.isAdmin === true && session.user.kind === "user",
83 run: (session, ctx) => ctx.push(requestsScreen(session, ctx)),
84 };
src/tui/screens/signin.ts +7 −1
@@ -9,7 +9,7 @@
99 */
1010
1111 import { activeEdgeToken } from "../../http/edge.ts";
12 import { INVITE_SPENT_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
12 import { INVITE_SPENT_MESSAGE, REQUEST_IN_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
1313 import { SignupSlot, waitText } from "../../actions/signup-window.ts";
1414 import { hasAnyUser } from "../../core/users.ts";
1515 import { getSignup } from "../../settings/signup.ts";
@@ -117,6 +117,12 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
117117 else error = errorText(created.error);
118118 return;
119119 }
120 // A request that waits has no session: the screen says so and stays a sign-in.
121 if (created.value.waitingAt !== null) {
122 mode = "signin";
123 error = REQUEST_IN_MESSAGE;
124 return;
125 }
120126 const adopted = f.adopt(created.value);
121127 if (!adopted.ok) {
122128 error = errorText(adopted.error);
src/tui/session.ts +1 −1
@@ -133,7 +133,7 @@ export class Session {
133133 password,
134134 source: this.identity.remoteAddress,
135135 });
136 if (!verified.ok) return err(verified.error.reason);
136 if (!verified.ok) return err(verified.error.reason === "waiting" ? "bad_credentials" : verified.error.reason);
137137 return this.adopt(verified.value);
138138 }
139139
src/web/pages/accounts.ts +3 −1
@@ -45,6 +45,7 @@ import { sessionsCard } from "./account.ts";
4545 import { ceilingCard, grantCard } from "./model.ts";
4646 import { accountSections } from "./origins.ts";
4747 import { markOf } from "../../core/marks.ts";
48 import { requestsCard } from "../requests_card.ts";
4849
4950 function shell(ctx: Ctx, body: Html, status = 200): Response {
5051 return page(
@@ -65,6 +66,7 @@ function shell(ctx: Ctx, body: Html, status = 200): Response {
6566 function standing(a: AccountRow): Html {
6667 if (a.removedAt !== null) return badge("Removed", { variant: "outline" });
6768 if (a.suspendedAt !== null) return badge("Suspended", { tone: "danger" });
69 if (a.waiting) return badge("Waiting", { variant: "outline" });
6870 if (!a.verified) return badge("Email not verified", { variant: "outline" });
6971 return html``;
7072 }
@@ -198,7 +200,7 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
198200 const rendering = performance.now();
199201 const response = shell(
200202 ctx,
201 html`${notice ?? null}${ownerless}${edgeOnly}${noMail}<section class="settings-block" id="accounts">
203 html`${notice ?? null}${requestsCard(ctx)}${ownerless}${edgeOnly}${noMail}<section class="settings-block" id="accounts">
202204 <h2>Who is on this forge</h2>
203205 <p class="muted">Newest first.</p>
204206 <ul class="list setting-list" id="accounts-list">
src/web/pages/admin-limits.ts +7 −4
@@ -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,16 +122,19 @@ 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">
128 <select class="input mono" name="signup" aria-label="Who may sign up">
128 <select class="input mono" name="signup" aria-label="New accounts">
129129 ${SIGNUP_MODES.map(
130130 (m) => html`<option value="${m}" ${signup.ok && signup.value.signup === m ? "selected" : ""}>${m}</option>`,
131131 )}
132132 </select>
133133 ${btn("Save", { size: "sm", variant: "primary", label: "Save signup" })}
134134 </form>
135 <ul class="muted small" id="signup-means">
136 ${SIGNUP_MODES.map((m) => html`<li><strong>${m}</strong>: ${SIGNUP_MEANS[m]}</li>`)}
137 </ul>

Design principle 1: 'Invite links still work.' is repeated on three lines. Say it once.

135138 </dd>
136139 <dd class="status-act"></dd>
137140 </div>`;
@@ -280,7 +283,7 @@ export async function signupSet(ctx: Ctx): Promise<Response> {
280283 "/admin/limits",
281284 changed.ok
282285 ? { message: "Signup saved." }
283 : { tone: "danger", message: "Signup is open, invite or closed." },
286 : { tone: "danger", message: "New accounts are open, request, invite or closed." },
284287 );
285288 }
286289
src/web/pages/passkeys.ts +6 −1
@@ -20,6 +20,7 @@ import {
2020 import { linkEdgeLogin } from "../../core/edge_links.ts";
2121 import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts";
2222 import { RateLimiter } from "../../core/ratelimit.ts";
23 import { requestOf } from "../../core/requests.ts";
2324 import { createSession } from "../../core/sessions.ts";
2425 import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts";
2526 import { recoveryAccount, requestRecovery, spendRecoveryLink } from "../../actions/verify.ts";
@@ -197,7 +198,8 @@ export async function signinPasskey(ctx: Ctx): Promise<Response> {
197198 const found = findPasskey(ctx.app.db, str(data.id));
198199 if (!found) return no();
199200 const user = getUserById(ctx.app.db, found.userId);
200 if (!user || !isActive(user)) return no();
201 const waiting = user !== undefined && user.waitingAt !== null && user.suspendedAt === null && user.removedAt === null;
202 if (!user || (!isActive(user) && !waiting)) return no();
201203 const verified = await verifyAssertion(
202204 {
203205 clientDataJSON: str(data.clientDataJSON),
@@ -209,6 +211,9 @@ export async function signinPasskey(ctx: Ctx): Promise<Response> {
209211 );
210212 if (!verified.ok) return no();
211213 notePasskeyUsed(ctx.app.db, found.id, verified.signCount);
214 // A proven key on an account whose request has no yes yet starts no session (#10928).
215 if (waiting)
216 return json({ ok: true, waiting: true, next: requestOf(ctx.app.db, user.id)?.state === "declined" ? "/signup/requested?answer=declined" : "/signup/requested" }, 202);
212217 const session = createSession(ctx.app.db, user.id);
213218 return json(
214219 { ok: true, next: safeNext(ctx.url.searchParams.get("next")) ?? "/" },
src/web/pages/signup-passkey.ts +3 −1
@@ -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) },
105 { username, email, passkey: made, passkeyName: "Passkey", humanToken: str(data.humanToken), note: str(data.note) },
106106 { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request) },
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 that waits starts no session: it lands on the page that says so.
111 if (created.value.waitingAt !== null) return json({ ok: true, waiting: true, next: "/signup/requested" }, 202);
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 +53 −3
@@ -18,12 +18,14 @@ 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 { NOTE_MAX, requestOf } from "../../core/requests.ts";
22 import { DECLINED_MESSAGE, REQUEST_IN_MESSAGE, STILL_WAITING_MESSAGE } from "../../actions/users.ts";
2123 import { HUMAN_CHECK_MESSAGE, INVITE_SPENT_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
2224 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2325 import { listPins } from "../../core/pins.ts";
2426 import { getReposByIds } from "../../core/repos.ts";
2527 import { createSession } from "../../core/sessions.ts";
26 import { hasAnyUser, verifyPasswordThrottled, type User } from "../../core/users.ts";
28 import { getUserByName, hasAnyUser, verifyPasswordThrottled, type User } from "../../core/users.ts";
2729 import { isThemeName } from "../../design/themes.ts";
2830 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
2931 import { studioDoor, studioHome } from "../studio/home.ts";
@@ -231,6 +233,7 @@ interface AuthErrors {
231233 /** What `/signin` says about new accounts in each signup mode; open says nothing. */
232234 const SIGNUP_SENTENCES: Record<SignupMode, string | undefined> = {
233235 open: undefined,
236 request: "New accounts here start after someone who runs the forge allows them.",
234237 invite: "This forge is invite-only: new accounts need an invite link.",
235238 closed: "This forge is closed to new accounts.",
236239 };
@@ -381,6 +384,17 @@ function authForm(
381384 <button type="button" id="passkey-signup" class="btn" data-variant="outline">Create account with a passkey</button>
382385 </div>`,
383386 )}
387 ${when(
388 kind === "signup" && getSignup(ctx.app.db) === "request",
389 () => formField({
390 label: "What do you want to build?",
391 name: "note",
392 type: "textarea",
393 rows: 3,
394 maxlength: NOTE_MAX,
395 hint: "Optional. A line or two helps whoever looks at your request.",
396 }),
397 )}
384398 ${when(
385399 errors.human,
386400 (message) => html`<p class="error mb" role="alert">${message}</p>`,
@@ -398,7 +412,7 @@ function authForm(
398412 ? html`<button type="button" id="passkey-signup" class="btn" data-variant="primary">Create account with a passkey</button>`
399413 : kind === "signin"
400414 ? html`<button type="submit" class="btn" data-variant="outline" data-passkey="on" hidden>Sign in</button><button type="submit" class="btn" data-variant="primary" data-passkey="off">Sign in</button>`
401 : btn("Create account", { variant: "primary" })}
415 : btn(kind === "signup" && getSignup(ctx.app.db) === "request" ? "Ask for an account" : "Create account", { variant: "primary" })}
402416 <span class="spacer"></span>
403417 ${kind === "signin"
404418 ? // A closed forge has no door to point at: the sentence above says so.
@@ -423,7 +437,7 @@ function authForm(
423437 const claimable =
424438 existsSync(join(ctx.app.config.dataDir, "parent.json")) && unowned;
425439 return html`<div class="narrow">
426 ${pageHead(kind === "signin" ? "Sign in" : "Create your account")}
440 ${pageHead(kind === "signin" ? "Sign in" : getSignup(ctx.app.db) === "request" ? "Ask for an account" : "Create your account")}
427441 ${when(
428442 kind === "signup",
429443 () => html`<p class="mb" id="signup-you-get">${SIGNUP_YOU_GET} <a href="/forges/new">${SIGNUP_OWN_FORGE}</a></p>`,
@@ -555,6 +569,11 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> {
555569 response.headers.set("retry-after", String(wait));
556570 return response;
557571 }
572 if (!verified.ok && verified.error.reason === "waiting") {
573 const mine = getUserByName(ctx.app.db, verified.error.username);
574 const declined = mine !== undefined && requestOf(ctx.app.db, mine.id)?.state === "declined";
575 return requestPage(ctx, declined ? DECLINED_MESSAGE : STILL_WAITING_MESSAGE, declined ? "Not this time" : "Your request is waiting");
576 }
558577 if (!verified.ok) {
559578 return page(
560579 {
@@ -580,6 +599,34 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> {
580599 );
581600 }
582601
602 /** What a person whose request is in, waiting or declined is shown: a sentence, no owner's name. */
603 export function requestPage(ctx: Ctx, said: string, title = "Your request is in", status = 200): Response {
604 return page(
605 {
606 ctx,
607 title,
608 body: html`<div class="narrow stack" id="request-state">
609 ${pageHead(title)}
610 <p>${said}</p>
611 <div class="form-actions">
612 ${btn("Sign in", { href: "/signin", variant: "outline" })}
613 ${btn("Get your own forge", { href: "/#yours", variant: "ghost" })}
614 </div>
615 </div>`,
616 },
617 status,
618 );
619 }
620
621 // GET /signup/requested — where a request lands: no session, nothing to act on yet.
622 export function requestedPage(ctx: Ctx): Response {
623 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
624 return timePhase(ctx.request, "render", () =>
625 ctx.url.searchParams.get("answer") === "declined"
626 ? requestPage(ctx, DECLINED_MESSAGE, "Not this time")
627 : requestPage(ctx, REQUEST_IN_MESSAGE));
628 }
629
583630 // GET /verify?token= — the link in a verification email.
584631 export function verifyPage(ctx: Ctx): Response {
585632 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -673,6 +720,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
673720 email,
674721 password: field(form, "password"),
675722 humanToken: field(form, "cf-turnstile-response"),
723 note: field(form, "note"),
676724 },
677725 { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request) },
678726 );
@@ -702,6 +750,8 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
702750 422,
703751 );
704752 }
753 // A request that waits has no session to start: it lands on the page that says so.
754 if (created.value.waitingAt !== null) return redirect("/signup/requested");
705755 const session = createSession(ctx.app.db, created.value.id);
706756 return redirect(
707757 safeNext(ctx.url.searchParams.get("next")) ?? "/",
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/requests_card.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * The Waiting list: strangers who asked for an account (#10928), each with
3 * the note they left, when, and from where, and Allow and Decline one tap
4 * apart. Shown on top of the accounts page and in home's "Needs you", for a
5 * forge admin; both answer through `actions/requests.ts`.
6 */
7
8 import { allowRequest, declineRequest, listRequests } from "../actions/requests.ts";
9 import type { Ctx } from "../http/context.ts";
10 import { notFoundPage, signInFirst } from "./chrome.ts";
11 import { readForm } from "./form.ts";
12 import { html, type Html } from "./html.ts";
13 import { flashTo } from "./layout.ts";
14 import { btn, row, time } from "./ui.ts";
15
16 /** The card, or nothing when nobody waits or the viewer is not an admin. */
17 export function requestsCard(ctx: Ctx, back = ""): Html | null {
18 const listed = listRequests(ctx.app, ctx.auth.user);
19 if (!listed.ok || listed.value.length === 0) return null;
20 return html`<section class="settings-block" id="waiting" aria-label="Waiting">
21 <h2>Waiting <span class="muted">${listed.value.length}</span></h2>
22 <ul class="list setting-list">
23 ${listed.value.map((r) => {
24 const at = `/settings/accounts/${encodeURIComponent(r.username)}`;
25 const to = back === "" ? "" : `?back=${back}`;
26 return row({
27 title: html`<strong>${r.username}</strong>`,
28 meta: html`asked ${time(r.askedAt)} · from ${r.source === "" ? "the web" : r.source}${r.note === ""
29 ? null
30 : html`<br />${r.note}`}`,
31 after: html`<form method="post" action="${at}/allow${to}">
32 ${btn("Allow", { type: "submit", variant: "primary" })}
33 </form>
34 <form method="post" action="${at}/decline${to}">
35 ${btn("Decline", { type: "submit", variant: "outline" })}
36 </form>`,
37 });
38 })}
39 </ul>
40 </section>`;
41 }
42
43 /** POST /settings/accounts/:username/allow and /decline. */
44 export function requestAct(act: "allow" | "decline") {
45 return async (ctx: Ctx): Promise<Response> => {
46 if (!ctx.auth.user) return signInFirst(ctx);
47 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
48 const form = await readForm(ctx, "admin");
49 if (form instanceof Response) return form;
50 const username = ctx.params.username ?? "";
51 const done = (act === "allow" ? allowRequest : declineRequest)(ctx.app, ctx.auth.user, username);
52 if (!done.ok && done.error === "not_found") return notFoundPage(ctx);
53 const to = ctx.url.searchParams.get("back") === "home" ? "/" : "/settings/accounts";
54 return flashTo(ctx, to, {
55 message: done.ok
56 ? `${username} ${act === "allow" ? "is in" : "was declined, politely"}.`
57 : `${username} is not waiting any more.`,
58 });
59 };
60 }
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. By request, POST ${o}/api/v1/users gives 202 {state:"waiting"}; sign in to see. If /signup says 403, use "Report a problem" below, or run your own forge.`,
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 +10 −2
@@ -20,7 +20,9 @@ import {
2020 } from "../../actions/front.ts";
2121 import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts";
2222 import { appsRuntime, type EnvState } from "../../apps/runtime.ts";
23 import { signupClosed } from "../../actions/users.ts";
23 import { signupClosed, YOURE_IN_MESSAGE } from "../../actions/users.ts";
24 import { markTold, untoldAnswer } from "../../core/requests.ts";
25 import { requestsCard } from "../requests_card.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,8 +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 // A stranger's request for an account is the first thing that needs an admin (#10928).
357 const requests = requestsCard(ctx, "home");
358 // The first page after Allow says so, once.
359 const allowed = untoldAnswer(ctx.app.db, user.id) === "allowed";
360 if (allowed) markTold(ctx.app.db, user.id, new Date().toISOString());
361 const needsYou = html`${requests}${waiting}${held}${alarms}`;
355362 const body = html`<h1 class="sr-only">Home</h1>
363 ${allowed ? html`<div class="toast" data-tone="success" role="status" id="youre-in"><strong>${YOURE_IN_MESSAGE}</strong> Your account is open: tell the crew what you want to build.</div>` : null}
356364 ${tellBox(ctx, user)}
357365 ${ask} ${address}
358366 ${when(String(needsYou).trim() !== "", () => html`<section class="stack stack-tight" id="needs-you" aria-label="Needs you"><h2 class="small muted">Needs you</h2>${needsYou}</section>`)}
test/arg-words.test.ts +1 −1
@@ -38,7 +38,7 @@ describe("an argument's allowed-values refusals", () => {
3838 "hidden is true or false",
3939 "op must be pin, unpin or move",
4040 "order is oldest or newest",
41 "signup is open, invite or closed",
41 "signup is open, request, invite or closed",
4242 "visibility is public or private",
4343 ]);
4444 });
test/conversation-confirm.test.ts +1 −1
@@ -120,7 +120,7 @@ describe("the one list of what asks first", () => {
120120 // Writes that do not ask first, on purpose: each reads, plans, or only changes the caller's own
121121 // settings and can be put back. A new `writes: true` tool is on neither list until someone decides.
122122 const NOT_ASKED = new Set([
123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas",
123 "access", "account_requests", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas",
124124 "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner",
126126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
test/mcp.test.ts +1
@@ -265,6 +265,7 @@ describe("the forge over MCP", () => {
265265 "invites",
266266 "account_role",
267267 "account_suspend",
268 "account_requests",
268269 "flag_winner",
269270 "restore_drill",
270271 "model_ceiling",
test/palette-roster.test.ts +1
@@ -145,6 +145,7 @@ test("a site-admin person is offered the whole roster", () => {
145145 ["teams", "Crew teams", "each team's charter, dial and members"],
146146 ["agents", "Crew agents", "each agent's model, budget and brief"],
147147 ["invites", "Invite links", "make a single-use link, see which are open, revoke one"],
148 ["requests", "Waiting", "strangers who asked for an account: allow or decline"],
148149 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
149150 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
150151 ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"],
test/request-access-limits.test.ts added +246
@@ -0,0 +1,246 @@
1 /**
2 * Request access, the edges (#10928): the migration leaves a closed forge
3 * closed, waiting requests are capped and the phone hears a count past a few,
4 * the terminal answers requests, and the pieces an answer rests on: a waiting
5 * passkey starts no session, a stored quota beats the newcomer's, an open
6 * invite vouches in `request` mode, an answer is made once, and the list is
7 * an admin's alone.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs";
12 import { readFileSync } from "node:fs";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { createApp, type App } from "../src/app.ts";
16 import { mint } from "../src/actions/invites.ts";
17 import { LINES_EACH, requestLines } from "../src/actions/requests.ts";
18 import { WAITING_CAP, waitingCount } from "../src/core/requests.ts";
19 import { createUser, getUserByName } from "../src/core/users.ts";
20 import { createSession } from "../src/core/sessions.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { NEWCOMER_QUOTAS, quotaOf, setQuota } from "../src/settings/quotas.ts";
23 import { setLimit } from "../src/settings/limits.ts";
24 import { getSignup, setSignup } from "../src/settings/signup.ts";
25 import { createForgeApp } from "../src/tui/forge.ts";
26 import { headlessTerminal } from "../src/tui/term/terminal.ts";
27 import { device } from "./helpers/passkey.ts";
28 import { adminCookie } from "./helpers/admin.ts";
29 import { quietLogs } from "./helpers/globals.ts";
30 import { settleApp } from "./helpers/tui.ts";
31
32 quietLogs();
33
34 const ORIGIN = "https://forge.example";
35 const PASS = "a-good-passphrase-1";
36
37 let dir: string;
38 let app: App;
39 let servers: Servers;
40 let base: string;
41 let admin: string;
42
43 beforeAll(async () => {
44 dir = await new Promise<string>((res, rej) => mkdtemp(join(tmpdir(), "forge-request-limits-"), (e, d) => (e ? rej(e) : res(d))));
45 app = createApp({ dataDir: dir, port: 0, url: ORIGIN });
46 servers = await serve(app);
47 base = servers.url;
48 setLimit(app.db, "signup.per_source", 1000, "setup");
49 admin = await adminCookie(app, { username: "ada", email: "[email protected]" });
50 setSignup(app.db, "request", "boot");
51 });
52 afterAll(async () => {
53 await servers.stop();
54 app.close();
55 await new Promise<void>((r) => rm(dir, { recursive: true, force: true }, () => r()));
56 });
57
58 const api = (username: string, extra: Record<string, string> = {}) =>
59 fetch(`${base}/api/v1/users`, {
60 method: "POST",
61 headers: { "content-type": "application/json", origin: ORIGIN },
62 body: JSON.stringify({ username, password: PASS, ...extra }),
63 });
64
65 /** Fill the Waiting list straight in the database: the cap is about rows, not about hashing. */
66 function fillWaiting(prefix: string, n: number): void {
67 const at = new Date().toISOString();
68 app.db.transaction(() => {
69 for (let i = 0; i < n; i++) {
70 const id = `fill-${prefix}-${i}`;
71 app.db.query("INSERT INTO user (id, username, email, password_hash, waiting_at) VALUES (?1, ?2, ?3, '', ?4)").run(id, `${prefix}${i}`, `${prefix}${i}@x.test`, at);
72 app.db.query("INSERT INTO account_request (user_id, note, source, state, asked_at) VALUES (?1, '', '', 'waiting', ?2)").run(id, at);
73 }
74 })();
75 }
76 const clearFill = (): void => {
77 app.db.query("DELETE FROM account_request WHERE user_id LIKE 'fill-%'").run();
78 app.db.query("DELETE FROM user WHERE id LIKE 'fill-%'").run();
79 };
80
81 describe("the migration", () => {
82 test("changes no stored setting, so a closed forge stays closed", () => {
83 const text = readFileSync(join(import.meta.dir, "../src/core/migrations/20261110160000-account-requests.ts"), "utf8");
84 expect(text).not.toMatch(/forge_setting/);
85 });
86 });
87
88 describe("the Waiting list is bounded", () => {
89 test("past the cap a stranger meets the closed door; an open invite still vouches; an answer reopens it", async () => {
90 clearFill();
91 fillWaiting("w", WAITING_CAP);
92 expect(waitingCount(app.db)).toBe(WAITING_CAP);
93 const refused = await api("onetoomany");
94 expect(refused.status).toBe(403);
95 expect(getUserByName(app.db, "onetoomany")).toBeUndefined();
96
97 const token = mint(app, getUserByName(app.db, "ada"), 7);
98 if (!token.ok) throw new Error("no invite");
99 const vouched = await api("vouched", { invite: token.value.token });
100 expect(vouched.status).toBe(201);
101 expect(getUserByName(app.db, "vouched")?.waitingAt).toBeNull();
102
103 app.db.query("DELETE FROM account_request WHERE user_id = 'fill-w-0'").run();
104 expect((await api("roomagain")).status).toBe(202);
105 clearFill();
106 });
107
108 test("the phone hears each of a few, then one count line", () => {
109 clearFill();
110 const ada = getUserByName(app.db, "ada")!;
111 app.db.query("DELETE FROM account_request WHERE state = 'waiting'").run();
112 fillWaiting("n", LINES_EACH);
113 expect(requestLines(app.db, ada).length).toBe(LINES_EACH);
114 fillWaiting("m", 40);
115 const many = requestLines(app.db, ada);
116 expect(many.length).toBe(1);
117 expect(many[0]!.line).toBe(`${LINES_EACH + 40} people ask for an account`);
118 fillWaiting("k", 1);
119 // A flood moves the id slowly: the same id for a few more.
120 expect(requestLines(app.db, ada)[0]!.id).toBe(many[0]!.id);
121 clearFill();
122 });
123 });
124
125 describe("what an answer rests on", () => {
126 test("a waiting account's passkey sign-in starts no session; once allowed it does", async () => {
127 const res = await fetch(`${base}/signup`, {
128 method: "POST",
129 headers: { origin: ORIGIN },
130 body: new URLSearchParams({ username: "keyed", email: "[email protected]", password: PASS }),
131 redirect: "manual",
132 });
133 expect(res.status).toBe(303);
134 const keyed = getUserByName(app.db, "keyed")!;
135 // Enrol a key as if the stranger had a session, then ask to be let in.
136 app.db.query("UPDATE user SET waiting_at = NULL WHERE id = ?1").run(keyed.id);
137 const cookie = `forge_session=${createSession(app.db, keyed.id).token}`;
138 const d = await device(ORIGIN);
139 const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
140 fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json", origin: ORIGIN, ...headers }, body: JSON.stringify(body), redirect: "manual" });
141 const opts = (await (await post("/settings/passkeys/options", {}, { cookie })).json()) as { challenge: string };
142 expect((await post("/settings/passkeys", { name: "k", challenge: opts.challenge, ...(await d.create(opts.challenge)) }, { cookie })).status).toBe(200);
143 const signIn = async () => {
144 const o = (await (await post("/signin/passkey/options", {})).json()) as { challenge: string };
145 return post("/signin/passkey", { challenge: o.challenge, ...(await d.get(o.challenge)) });
146 };
147 app.db.query("UPDATE user SET waiting_at = ?1 WHERE id = ?2").run(new Date().toISOString(), keyed.id);
148 const waiting = await signIn();
149 expect(waiting.status).toBe(202);
150 expect(waiting.headers.get("set-cookie")).toBeNull();
151 app.db.query("UPDATE user SET waiting_at = NULL WHERE id = ?1").run(keyed.id);
152 const allowed = await signIn();
153 expect(allowed.status).toBe(200);
154 expect(allowed.headers.get("set-cookie")).toContain("forge_session=");
155 });
156
157 test("a stored quota beats the newcomer's; the newcomer's holds only for an allowed request", async () => {
158 expect((await api("quota1")).status).toBe(202);
159 const person = getUserByName(app.db, "quota1")!;
160 expect(quotaOf(app.db, "apps", person.id)).toBeNull();
161 const allow = await fetch(`${base}/api/v1/admin/requests/quota1/allow`, { method: "POST", headers: { cookie: admin, origin: ORIGIN } });
162 expect(allow.status).toBe(200);
163 expect(quotaOf(app.db, "apps", person.id)).toBe(NEWCOMER_QUOTAS.apps);
164 setQuota(app.db, "apps", (NEWCOMER_QUOTAS.apps ?? 0) + 40, "ada");
165 expect(quotaOf(app.db, "apps", person.id)).toBe((NEWCOMER_QUOTAS.apps ?? 0) + 40);
166 });
167
168 test("an open invite vouches in request mode: no wait, and the invite is spent", async () => {
169 expect(getSignup(app.db)).toBe("request");
170 const token = mint(app, getUserByName(app.db, "ada"), 7);
171 if (!token.ok) throw new Error("no invite");
172 const res = await api("guest1", { invite: token.value.token });
173 expect(res.status).toBe(201);
174 expect(getUserByName(app.db, "guest1")?.waitingAt).toBeNull();
175 const again = await api("guest2", { invite: token.value.token });
176 expect(again.status).toBe(202);
177 expect(getUserByName(app.db, "guest2")?.waitingAt).not.toBeNull();
178 });
179
180 test("an answer is made once, and the list is an admin's alone", async () => {
181 expect((await api("once")).status).toBe(202);
182 const answer = () => fetch(`${base}/api/v1/admin/requests/once/decline`, { method: "POST", headers: { cookie: admin, origin: ORIGIN } });
183 expect((await answer()).status).toBe(200);
184 expect((await answer()).status).toBe(409);
185 const member = await createUser(app.db, { origin: "seed", trusted: true, username: "plain", email: "[email protected]", password: PASS });
186 if (!member.ok) throw new Error(member.error);
187 const cookie = `forge_session=${createSession(app.db, member.value.id).token}`;
188 const got = await fetch(`${base}/api/v1/admin/requests`, { headers: { cookie } });
189 // The admin gate answers a member before the handler does; anonymous gets 404.
190 expect(got.status).toBe(403);
191 expect(await got.text()).not.toContain("guest2");
192 expect((await fetch(`${base}/api/v1/admin/requests`)).status).toBe(404);
193 const post = await fetch(`${base}/api/v1/admin/requests/guest2/allow`, { method: "POST", headers: { cookie, origin: ORIGIN } });
194 expect([403, 404]).toContain(post.status);
195 expect(getUserByName(app.db, "guest2")?.waitingAt).not.toBeNull();
196 });
197 });
198
199 describe("the terminal answers requests", () => {
200 const open = (username: string) => {
201 const user = getUserByName(app.db, username)!;
202 const { app: tui } = createForgeApp(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal: headlessTerminal(140, 32), exitHooks: false });
203 tui.start();
204 const k = (key: string, ctrl = false) => tui.dispatch({ type: "key", key, ctrl, alt: false, shift: false });
205 return {
206 text: () => tui.screen.toText(),
207 press: async (...keys: string[]) => {
208 keys.forEach((x) => k(x));
209 await settleApp(tui);
210 },
211 palette: async () => {
212 k("k", true);
213 for (const ch of "Waiting") k(ch);
214 k("enter");
215 await settleApp(tui);
216 },
217 stop: () => tui.stop(),
218 };
219 };
220
221 test("Allow and Decline on the selected row, through the same action", async () => {
222 clearFill();
223 app.db.query("DELETE FROM account_request WHERE state = 'waiting'").run();
224 expect((await api("tuia")).status).toBe(202);
225 expect((await api("tuib")).status).toBe(202);
226 const h = open("ada");
227 await h.palette();
228 expect(h.text()).toContain("tuia");
229 expect(h.text()).toContain("tuib");
230 await h.press("a");
231 expect(h.text()).toContain("tuia is in.");
232 expect(getUserByName(app.db, "tuia")?.waitingAt).toBeNull();
233 await h.press("d");
234 expect(h.text()).toContain("tuib was declined");
235 expect(getUserByName(app.db, "tuib")?.waitingAt).not.toBeNull();
236 expect(waitingCount(app.db)).toBe(0);
237 h.stop();
238 });
239
240 test("a non-admin is offered no Waiting screen", async () => {
241 const h = open("plain");
242 await h.palette();
243 expect(h.text()).not.toContain("strangers who asked");
244 h.stop();
245 });
246 });
test/request-access.test.ts added +164
@@ -0,0 +1,164 @@
1 /**
2 * Request access (#10928): in `request` mode a stranger asks for an account
3 * and waits, with no credential, until an admin allows it; Decline keeps a
4 * record and tells them politely. Proven by API and by page.
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 { setLimit } from "../src/settings/limits.ts";
13 import { getSignup, setSignup } from "../src/settings/signup.ts";
14 import { NEWCOMER_QUOTAS, quotaOf } from "../src/settings/quotas.ts";
15 import { getUserByName } from "../src/core/users.ts";
16 import { requestLines } from "../src/actions/requests.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { adminCookie } from "./helpers/admin.ts";
19 import { roleOf } from "../src/core/roles.ts";
20
21 const ORIGIN = "https://forge.example";
22 const PASS = "a-good-passphrase-1";
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 base = servers.url;
37 admin = await adminCookie(app, { username: "ada", email: "[email protected]" });
38 setSignup(app.db, "request", "boot");
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, password: PASS, ...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: PASS });
60
61 test("the page asks for a note and the request page says so plainly, with no owner's name", 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("Ask for an account");
65 const res = await form("/signup", { username: "pagey", password: PASS, note: "a <b>shop</b> page" });
66 expect(res.status).toBe(303);
67 expect(res.headers.get("set-cookie")).toBeNull();
68 expect(res.headers.get("location")).toBe("/signup/requested");
69 const said = await fetch(`${base}/signup/requested`).then((r) => r.text());
70 expect(said).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.");
71 expect(said).not.toContain("ada");
72 // It waits: signing in shows the wait and starts no session.
73 const again = await signIn("pagey");
74 expect(again.status).toBe(200);
75 expect(again.headers.get("set-cookie")).toBeNull();
76 expect(await again.text()).toContain("still waiting");
77 expect(getUserByName(app.db, "pagey")?.waitingAt).not.toBeNull();
78 });
79
80 test("by API: 202 waiting, no credential, the admin sees it listed and allows it with one POST", async () => {
81 const res = await api("apiy", { note: "a game", from: "agent:friend" });
82 expect(res.status).toBe(202);
83 expect(((await res.json()) as { state: string }).state).toBe("waiting");
84 const user = getUserByName(app.db, "apiy")!;
85 expect(roleOf(app.db, user)).toBe("guest");
86 // A waiting account has no way in at any door.
87 const basic = `Basic ${btoa(`apiy:${PASS}`)}`;
88 expect((await fetch(`${base}/api/v1/user`, { headers: { authorization: basic } })).status).toBe(401);
89
90 const listed = (await fetch(`${base}/api/v1/admin/requests`, { headers: { cookie: admin } }).then((r) => r.json())) as {
91 items: { username: string; note: string; source: string }[];
92 };
93 const mine = listed.items.find((i) => i.username === "apiy");
94 expect(mine).toMatchObject({ note: "a game", source: "agent:friend" });
95 // Not an admin's to read.
96 expect((await fetch(`${base}/api/v1/admin/requests`)).status).toBe(404);
97
98 expect(requestLines(app.db, getUserByName(app.db, "ada")!).some((l) => l.id === "request:apiy")).toBe(true);
99
100 const done = await fetch(`${base}/api/v1/admin/requests/apiy/allow`, { method: "POST", headers: { cookie: admin, origin: ORIGIN } });
101 expect(done.status).toBe(200);
102 expect(getUserByName(app.db, "apiy")?.waitingAt).toBeNull();
103 // Answered once: a second tap finds nothing waiting.
104 expect((await fetch(`${base}/api/v1/admin/requests/apiy/allow`, { method: "POST", headers: { cookie: admin, origin: ORIGIN } })).status).toBe(409);
105 // Allowed: a member under the small newcomer quotas, no model grant.
106 expect(roleOf(app.db, getUserByName(app.db, "apiy")!)).toBe("member");
107 expect(quotaOf(app.db, "apps", getUserByName(app.db, "apiy")!.id)).toBe(NEWCOMER_QUOTAS.apps);
108 expect(quotaOf(app.db, "apps", getUserByName(app.db, "ada")!.id)).toBeNull();
109 const ok = await fetch(`${base}/api/v1/user`, { headers: { authorization: basic } });
110 expect(ok.status).toBe(200);
111 });
112
113 test("by page: Waiting shows name, note and source with Allow; allowing makes the next load say You're in, once", async () => {
114 const accounts = await fetch(`${base}/settings/accounts`, { headers: { cookie: admin } }).then((r) => r.text());
115 expect(accounts).toContain('id="waiting"');
116 expect(accounts).toContain("pagey");
117 expect(accounts).toContain("a &lt;b&gt;shop&lt;/b&gt; page");
118 expect(accounts).toContain("/settings/accounts/pagey/allow");
119 expect(accounts.indexOf('id="waiting"')).toBeLessThan(accounts.indexOf('id="accounts"'));
120 // Only an admin, from this origin, may answer.
121 expect(getUserByName(app.db, "pagey")?.waitingAt).not.toBeNull();
122 const allow = await form("/settings/accounts/pagey/allow?back=home", {}, admin);
123 expect(allow.status).toBe(303);
124 const signedIn = await signIn("pagey");
125 expect(signedIn.status).toBe(303);
126 const cookie = (signedIn.headers.get("set-cookie") ?? "").split(";")[0]!;
127 const home = await fetch(`${base}/`, { headers: { cookie } }).then((r) => r.text());
128 expect(home).toContain('id="youre-in"');
129 const next = await fetch(`${base}/`, { headers: { cookie } }).then((r) => r.text());
130 expect(next).not.toContain('id="youre-in"');
131 });
132
133 test("Decline keeps a record, says no politely, and the account never acts", async () => {
134 expect((await api("nope")).status).toBe(202);
135 const res = await form("/settings/accounts/nope/decline", {}, admin);
136 expect(res.status).toBe(303);
137 const user = getUserByName(app.db, "nope")!;
138 expect(user.waitingAt).not.toBeNull();
139 const row = app.db.query<{ state: string }, [string]>("SELECT state FROM account_request WHERE user_id = ?1").get(user.id);
140 expect(row?.state).toBe("declined");
141 const shown = await signIn("nope");
142 expect(shown.headers.get("set-cookie")).toBeNull();
143 expect(await shown.text()).toContain("Thanks for asking");
144 const list = (await fetch(`${base}/api/v1/admin/requests`, { headers: { cookie: admin } }).then((r) => r.json())) as { items: { username: string }[] };
145 expect(list.items.find((i) => i.username === "nope")).toBeUndefined();
146 expect((await fetch(`${base}/api/v1/admin/requests/nope/allow`, { method: "POST", headers: { cookie: admin, origin: ORIGIN } })).status).toBe(409);
147 });
148
149 test("the knob has four modes; invite links work in all but closed; open starts at once", async () => {
150 expect(getSignup(app.db)).toBe("request");
151 const settings = await fetch(`${base}/admin/limits`, { headers: { cookie: admin } }).then((r) => r.text());
152 expect(settings).toContain('value="request"');
153 setSignup(app.db, "open", "boot");
154 const res = await api("opener");
155 expect(res.status).toBe(201);
156 const opener = getUserByName(app.db, "opener")!;
157 expect(opener.waitingAt).toBeNull();
158 // Open: a guest, who makes no repository, app or crew run until an admin promotes them.
159 expect(roleOf(app.db, opener)).toBe("guest");
160 setSignup(app.db, "closed", "boot");
161 expect((await api("shut")).status).toBe(403);
162 setSignup(app.db, "request", "boot");
163 });
164 });
test/schema-migrations.test.ts +5 −5
@@ -377,8 +377,8 @@ describe("a migration is a file named by a sortable id (#1837)", () => {
377377 test("two pulls' migrations land in either order and the schema ends the same", () => {
378378 // Two builders, each adding one migration at the second they wrote it:
379379 // a's after b's, so b sorts first whichever lands first.
380 const a = { "20261015120000-deploy-note.ts": "CREATE TABLE deploy_note (id TEXT PRIMARY KEY, body TEXT NOT NULL DEFAULT '')" };
381 const b = { "20261015110000-user-motto.ts": "ALTER TABLE user ADD COLUMN motto TEXT NOT NULL DEFAULT ''; CREATE INDEX user_motto ON user(motto)" };
380 const a = { "20991231120000-deploy-note.ts": "CREATE TABLE deploy_note (id TEXT PRIMARY KEY, body TEXT NOT NULL DEFAULT '')" };
381 const b = { "20991231110000-user-motto.ts": "ALTER TABLE user ADD COLUMN motto TEXT NOT NULL DEFAULT ''; CREATE INDEX user_motto ON user(motto)" };
382382 const both = listIn(directory({ ...a, ...b }));
383383 expect(both.length).toBe(migrations.length + 2);
384384
@@ -397,12 +397,12 @@ describe("a migration is a file named by a sortable id (#1837)", () => {
397397 for (const path of [aFirst, bFirst, together]) {
398398 const db = new Database(path, { readonly: true, strict: true });
399399 const names = db
400 .query<{ name: string }, []>("SELECT name FROM schema_migration WHERE name LIKE '2026101511%' OR name LIKE '2026101512%' ORDER BY name")
400 .query<{ name: string }, []>("SELECT name FROM schema_migration WHERE name LIKE '2099123111%' OR name LIKE '2099123112%' ORDER BY name")
401401 .all()
402402 .map((row) => row.name);
403403 db.close();
404 expect(names).toContain("20261015110000-user-motto");
405 expect(names).toContain("20261015120000-deploy-note");
404 expect(names).toContain("20991231110000-user-motto");
405 expect(names).toContain("20991231120000-deploy-note");
406406 }
407407 const db = openDb(together, both);
408408 expect(has(db, "deploy_note")).toBe(true);
test/tui-signup.test.ts +3 −3
@@ -73,15 +73,15 @@ test("reads open, esc changes nothing, a confirm closes the door", async () => {
7373 const h = boot("alice");
7474 await h.open();
7575 expect(h.text()).toContain("signup: open");
76 // picker, closed, then esc on the plan
77 await h.press("s", "j", "j", "enter");
76 // picker (open, request, invite, closed), closed, then esc on the plan
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");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs