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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10928-request-access-a-stranger-can-ask-for-an → main 8 commits fork 4 threads · 4 unresolved
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`; | ||
| 42 | 42 | | Tool | Actions / kinds | Purpose | Bytes | |
| 43 | 43 | | --- | --- | --- | ---: | |
| 44 | 44 | | `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 | | |
| 45 | 46 | | `account_role` | — | Make a person a member or a guest; promoting may grant `usdPerMonth` | 374 | |
| 46 | 47 | | `account_suspend` | — | Suspend an account; `suspended: false` lifts | 231 | |
| 47 | 48 | | `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`; | ||
| 193 | 194 | | `sessions` | `list`, `end`, `end_idle`, `signins`, `shells` | Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner; action end + id signs a browser out | 385 | |
| 194 | 195 | | `sever` | — | Unlink a spawned forge from its parent (owner of its `forge` repo only) | 214 | |
| 195 | 196 | | `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 | | |
| 197 | 198 | | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 | |
| 198 | 199 | | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 | |
| 199 | 200 | | `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`, | ||
| 130 | 130 | - `/login` — `http/server.ts` (`signinAlias`) · the address people type to sign in · it is `/signin`, keeping a same-origin `next` · none (308, no page of its own). |
| 131 | 131 | - `/sign-in` — `http/server.ts` (`signinAlias`) · the same, hyphenated · it is `/signin` · none (308, no page of its own). |
| 132 | 132 | - `/signup` — `pages/site.ts` · make an account · `/signup` passkey flow (`signup-passkey.ts`) · colours 3 (file). |
| 133 | - `/signup/requested` — `pages/site.ts` · what a stranger whose request waits is told · `/signin` · colours 3 (file). | |
| 133 | 134 | - `/verify` — `pages/site.ts` · confirm an email address · none · colours 3 (file). |
| 134 | 135 | - `/recover` — `pages/passkeys.ts` · get back in without a password · `/settings/passkeys` · none seen. |
| 135 | 136 | - `/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 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | import { getQuota } from "../settings/quotas.ts"; | |
| 10 | import { quotaOf } from "../settings/quotas.ts"; | |
| 11 | 11 | import type { User } from "../core/users.ts"; |
| 12 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | 13 | |
| @@ -24,7 +24,7 @@ export function appsOwnedBy(app: App, ownerId: string): number { | ||
| 24 | 24 | |
| 25 | 25 | /** The refusal, in words: the limit and the count, e.g. "3 of 3 apps". */ |
| 26 | 26 | 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; | |
| 28 | 28 | return `This account is at its limit: ${Math.min(appsOwnedBy(app, ownerId), limit)} of ${limit} apps. Remove one to make room.`; |
| 29 | 29 | } |
| 30 | 30 | |
| @@ -35,7 +35,7 @@ export function requireAppRoom( | ||
| 35 | 35 | ownerId: string, |
| 36 | 36 | ): Result<void, "app_limit"> { |
| 37 | 37 | if (actor.isAdmin) return ok(); |
| 38 | const limit = getQuota(app.db, "apps"); | |
| 38 | const limit = quotaOf(app.db, "apps", ownerId); | |
| 39 | 39 | if (limit === null || appsOwnedBy(app, ownerId) < limit) return ok(); |
| 40 | 40 | return err("app_limit"); |
| 41 | 41 | } |
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 []; | |
| 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 { | ||
| 27 | 27 | import { mintViaToken, VIA_TOKEN_MAX_MS } from "../core/via_token.ts"; |
| 28 | 28 | import { id } from "../kernel/id.ts"; |
| 29 | 29 | import { inviteSpentBy } from "../core/invites.ts"; |
| 30 | import { cleanAsk, NOTE_MAX, SOURCE_MAX } from "../core/requests.ts"; | |
| 30 | 31 | import { |
| 31 | 32 | changePassword as rehash, |
| 32 | 33 | cleanDisplayName, |
| @@ -52,6 +53,9 @@ export interface SignUpInput { | ||
| 52 | 53 | password: string; |
| 53 | 54 | /** The Turnstile token the browser's widget gave; read only when a secret is set. */ |
| 54 | 55 | humanToken?: string; |
| 56 | /** While signup is `request`: what they want to build, and which forge or agent asks (`core/requests.ts`). */ | |
| 57 | note?: string; | |
| 58 | source?: string; | |
| 55 | 59 | } |
| 56 | 60 | |
| 57 | 61 | export type SignUpError = |
| @@ -64,6 +68,18 @@ export type SignUpError = | ||
| 64 | 68 | /** What anyone is told when the forge makes no new accounts. */ |
| 65 | 69 | export const SIGNUP_CLOSED_MESSAGE = "Signup is closed on this forge."; |
| 66 | 70 | |
| 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 | ||
| 67 | 83 | /** |
| 68 | 84 | * Whether no new account may be made now. Read from the store on every call, |
| 69 | 85 | * so a change takes effect on the next request. While signup is `invite`, an |
| @@ -148,6 +164,7 @@ export async function signUp( | ||
| 148 | 164 | origin: "signup", |
| 149 | 165 | slot: from.slot, |
| 150 | 166 | invite: from.invite, |
| 167 | ask: { note: cleanAsk(input.note, NOTE_MAX), source: cleanAsk(input.source, SOURCE_MAX) }, | |
| 151 | 168 | }); |
| 152 | 169 | if (!created.ok) return created; |
| 153 | 170 | app.bus.append({ |
| @@ -155,13 +172,14 @@ export async function signUp( | ||
| 155 | 172 | actorId: created.value.id, |
| 156 | 173 | payload: { |
| 157 | 174 | via: "signup", |
| 175 | ...(created.value.waitingAt === null ? {} : { waiting: true }), | |
| 158 | 176 | ...(from.address === undefined ? {} : { address: from.address }), |
| 159 | 177 | }, |
| 160 | 178 | }); |
| 161 | 179 | auditInvite(app, created.value.id); |
| 162 | 180 | // 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); | |
| 165 | 183 | return created; |
| 166 | 184 | } |
| 167 | 185 | |
| @@ -172,6 +190,8 @@ export interface PasskeySignUpInput { | ||
| 172 | 190 | passkey: Credential; |
| 173 | 191 | passkeyName: string; |
| 174 | 192 | humanToken?: string; |
| 193 | note?: string; | |
| 194 | source?: string; | |
| 175 | 195 | } |
| 176 | 196 | |
| 177 | 197 | /** |
| @@ -202,6 +222,7 @@ export async function signUpWithPasskey( | ||
| 202 | 222 | origin: "passkey", |
| 203 | 223 | slot: from.slot, |
| 204 | 224 | invite: from.invite, |
| 225 | ask: { note: cleanAsk(input.note, NOTE_MAX), source: cleanAsk(input.source, SOURCE_MAX) }, | |
| 205 | 226 | }); |
| 206 | 227 | if (!created.ok) return created; |
| 207 | 228 | const kept = addPasskey(app.db, created.value.id, input.passkeyName, input.passkey); |
| @@ -214,12 +235,13 @@ export async function signUpWithPasskey( | ||
| 214 | 235 | actorId: created.value.id, |
| 215 | 236 | payload: { |
| 216 | 237 | via: "signup", |
| 238 | ...(created.value.waitingAt === null ? {} : { waiting: true }), | |
| 217 | 239 | ...(from.address === undefined ? {} : { address: from.address }), |
| 218 | 240 | }, |
| 219 | 241 | }); |
| 220 | 242 | auditInvite(app, created.value.id); |
| 221 | 243 | // 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); | |
| 223 | 245 | return created; |
| 224 | 246 | } |
| 225 | 247 | |
| @@ -402,7 +424,7 @@ export async function changePassword( | ||
| 402 | 424 | password: input.current, |
| 403 | 425 | source: input.source, |
| 404 | 426 | }); |
| 405 | if (!verified.ok) return err(verified.error.reason); | |
| 427 | if (!verified.ok) return err(verified.error.reason === "waiting" ? "bad_credentials" : verified.error.reason); | |
| 406 | 428 | const changed = await rehash(app.db, user.value.id, input.next); |
| 407 | 429 | if (!changed.ok) |
| 408 | 430 | 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"; | ||
| 47 | 47 | export { route as getApiV1AdminPinned } from "./getApiV1AdminPinned.ts"; |
| 48 | 48 | export { route as getApiV1AdminProfile } from "./getApiV1AdminProfile.ts"; |
| 49 | 49 | export { route as getApiV1AdminQuotas } from "./getApiV1AdminQuotas.ts"; |
| 50 | export { route as getApiV1AdminRequests } from "./getApiV1AdminRequests.ts"; | |
| 50 | 51 | export { route as getApiV1AdminSettings } from "./getApiV1AdminSettings.ts"; |
| 51 | 52 | export { route as getApiV1AdminShield } from "./getApiV1AdminShield.ts"; |
| 52 | 53 | export { route as getApiV1AdminSubscriptions } from "./getApiV1AdminSubscriptions.ts"; |
| @@ -242,6 +243,8 @@ export { route as postApiV1AdminPinnedNameCheck } from "./postApiV1AdminPinnedNa | ||
| 242 | 243 | export { route as postApiV1AdminPinnedNameRollback } from "./postApiV1AdminPinnedNameRollback.ts"; |
| 243 | 244 | export { route as postApiV1AdminPinnedNameUpdate } from "./postApiV1AdminPinnedNameUpdate.ts"; |
| 244 | 245 | export { route as postApiV1AdminReleasesShaRollback } from "./postApiV1AdminReleasesShaRollback.ts"; |
| 246 | export { route as postApiV1AdminRequestsUsernameAllow } from "./postApiV1AdminRequestsUsernameAllow.ts"; | |
| 247 | export { route as postApiV1AdminRequestsUsernameDecline } from "./postApiV1AdminRequestsUsernameDecline.ts"; | |
| 245 | 248 | export { route as postApiV1Agents } from "./postApiV1Agents.ts"; |
| 246 | 249 | export { route as postApiV1Apps } from "./postApiV1Apps.ts"; |
| 247 | 250 | 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 = { | ||
| 4 | 4 | method: "post", |
| 5 | 5 | path: "/api/v1/users", |
| 6 | 6 | summary: |
| 7 | "Sign up: answers 201 with the new account { id, username, displayName, isAdmin, commitEmail }, never the admin; `email` is optional; `invite` is an invite link's token, `turnstile_token` the human check when the forge asks for one; refused from another origin with 403 forbidden", | |
| 7 | "Sign up: 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", | |
| 8 | 8 | auth: false, |
| 9 | 9 | role: "anonymous", |
| 10 | 10 | status: 201, |
| 11 | body: { username: "string", email: "string", password: "string", invite: "string", turnstile_token: "string" }, | |
| 11 | body: { username: "string", email: "string", password: "string", invite: "string", note: "string", from: "string", turnstile_token: "string" }, | |
| 12 | 12 | refusals: { |
| 13 | 13 | rate_limited: "429, too many sign-ups; retry-after says when", |
| 14 | 14 | 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 { | ||
| 32 | 32 | readonly spentUsd: number; |
| 33 | 33 | readonly suspendedAt: string | null; |
| 34 | 34 | readonly removedAt: string | null; |
| 35 | /** Whether a request for this account waits for an answer (\`core/requests.ts\`). */ | |
| 36 | readonly waiting: boolean; | |
| 35 | 37 | /** Whether the email is proven (\`core/verify.ts\`); agents always are. */ |
| 36 | 38 | readonly verified: boolean; |
| 37 | 39 | } |
| @@ -58,6 +60,7 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 58 | 60 | created_at: string; |
| 59 | 61 | suspended_at: string | null; |
| 60 | 62 | removed_at: string | null; |
| 63 | waiting_at: string | null; | |
| 61 | 64 | verified_at: string | null; |
| 62 | 65 | repos: number; |
| 63 | 66 | apps: number; |
| @@ -67,7 +70,7 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 67 | 70 | [] |
| 68 | 71 | >( |
| 69 | 72 | `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, | |
| 71 | 74 | (SELECT c.verified_at FROM email_check c WHERE c.user_id = u.id) AS verified_at, |
| 72 | 75 | (SELECT COUNT(*) FROM repo r WHERE r.owner_id = u.id) AS repos, |
| 73 | 76 | (SELECT COUNT(*) FROM app a JOIN repo r ON r.id = a.repo_id |
| @@ -97,6 +100,7 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 97 | 100 | spentUsd: Math.round((r.spent ?? 0) * 100) / 100, |
| 98 | 101 | suspendedAt: r.suspended_at, |
| 99 | 102 | removedAt: r.removed_at, |
| 103 | waiting: r.waiting_at !== null && r.removed_at === null, | |
| 100 | 104 | verified: r.is_agent === 1 || r.verified_at !== null, |
| 101 | 105 | }; |
| 102 | 106 | }); |
src/core/arg_words.ts +1 −1
| @@ -20,7 +20,7 @@ export const PIN_OP_WORDS = "op must be pin, unpin or move"; | ||
| 20 | 20 | export const ORDER_WORDS = "order is oldest or newest"; |
| 21 | 21 | |
| 22 | 22 | /** 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"; | |
| 24 | 24 | |
| 25 | 25 | /** A repository's `visibility`: `public` or `private`. */ |
| 26 | 26 | 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([ | ||
| 133 | 133 | "issue.teams", |
| 134 | 134 | "account.role_set", |
| 135 | 135 | "account.email_verified", |
| 136 | "account.allowed", | |
| 137 | "account.declined", | |
| 136 | 138 | "account.recovered", |
| 137 | 139 | "limb.replan", |
| 138 | 140 | "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"; | ||
| 17 | 17 | import { dirBytes } from "../apps/bytes.ts"; |
| 18 | 18 | import { repoDir } from "../git/repos.ts"; |
| 19 | 19 | import { limitValue } from "../settings/limits.ts"; |
| 20 | import { getQuota } from "../settings/quotas.ts"; | |
| 20 | import { getQuota, quotaOf } from "../settings/quotas.ts"; | |
| 21 | 21 | import { contributionForksOf, isContributionFork } from "./contribute.ts"; |
| 22 | 22 | import type { Repo } from "./repos.ts"; |
| 23 | 23 | import type { User } from "./users.ts"; |
| @@ -190,8 +190,8 @@ export function formatBytes(bytes: number): string { | ||
| 190 | 190 | } |
| 191 | 191 | |
| 192 | 192 | /** 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); | |
| 195 | 195 | return mb === null ? undefined : mb * BYTES_PER_MB; |
| 196 | 196 | } |
| 197 | 197 | |
| @@ -217,7 +217,7 @@ export async function storageRefusal( | ||
| 217 | 217 | const guest = await contributionRefusal(app, ownerId, repo, user); |
| 218 | 218 | if (guest !== undefined) return guest; |
| 219 | 219 | } |
| 220 | const limit = storageLimitBytes(app); | |
| 220 | const limit = storageLimitBytes(app, ownerId); | |
| 221 | 221 | if (limit === undefined || storageExempt(user)) return undefined; |
| 222 | 222 | let used = await accountBytes(app, ownerId); |
| 223 | 223 | // At the limit, look again: a repository deleted or data cleared since the |
| @@ -240,7 +240,7 @@ export const storageRefusedText = (used: string): string => | ||
| 240 | 240 | |
| 241 | 241 | /** The sentence for a refusal just given, from the figure it was given on. */ |
| 242 | 242 | export function storageSentence(app: App, ownerId: string): string | undefined { |
| 243 | const limit = storageLimitBytes(app); | |
| 243 | const limit = storageLimitBytes(app, ownerId); | |
| 244 | 244 | const used = cachedBytes(app, ownerId); |
| 245 | 245 | return limit === undefined || used === undefined || used < limit |
| 246 | 246 | ? undefined |
| @@ -259,7 +259,7 @@ export async function pushCap( | ||
| 259 | 259 | repo?: Repo, |
| 260 | 260 | ): Promise<Record<string, string>> { |
| 261 | 261 | if (storageExempt(user)) return {}; |
| 262 | const limit = storageLimitBytes(app); | |
| 262 | const limit = storageLimitBytes(app, ownerId); | |
| 263 | 263 | const guest = |
| 264 | 264 | repo !== undefined && isContributionFork(app.db, repo.id) |
| 265 | 265 | ? (await contributionRoom(app, ownerId, repo)).room |
src/core/users.ts +53 −9
| @@ -11,6 +11,7 @@ import { reservedName } from "./reserved.ts"; | ||
| 11 | 11 | import { id } from "../kernel/id.ts"; |
| 12 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | 13 | import { admitSignin } from "./signin-window.ts"; |
| 14 | import { waitingCount, WAITING_CAP } from "./requests.ts"; | |
| 14 | 15 | import { getSignup } from "../settings/signup.ts"; |
| 15 | 16 | import { inviteOpen, redeemInvite, reopenInvite } from "./invites.ts"; |
| 16 | 17 | import type { SignupSlot } from "../actions/signup-window.ts"; |
| @@ -38,6 +39,12 @@ export interface User { | ||
| 38 | 39 | readonly suspendedAt: string | null; |
| 39 | 40 | /** When the owner removed it. The row stays so the name stays taken. */ |
| 40 | 41 | 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; | |
| 41 | 48 | } |
| 42 | 49 | |
| 43 | 50 | export type CommitEmail = "noreply" | "account"; |
| @@ -55,6 +62,7 @@ interface UserRow { | ||
| 55 | 62 | created_at: string; |
| 56 | 63 | suspended_at: string | null; |
| 57 | 64 | removed_at: string | null; |
| 65 | waiting_at: string | null; | |
| 58 | 66 | } |
| 59 | 67 | |
| 60 | 68 | function toUser(row: UserRow): User { |
| @@ -71,6 +79,7 @@ function toUser(row: UserRow): User { | ||
| 71 | 79 | createdAt: row.created_at, |
| 72 | 80 | suspendedAt: row.suspended_at, |
| 73 | 81 | removedAt: row.removed_at, |
| 82 | waitingAt: row.waiting_at ?? null, | |
| 74 | 83 | }; |
| 75 | 84 | } |
| 76 | 85 | |
| @@ -79,7 +88,7 @@ function toUser(row: UserRow): User { | ||
| 79 | 88 | * password, edge sign-in, SSH key — asks this before it names a person. |
| 80 | 89 | */ |
| 81 | 90 | export const isActive = (user: User): boolean => |
| 82 | user.suspendedAt === null && user.removedAt === null; | |
| 91 | user.suspendedAt === null && user.removedAt === null && user.waitingAt === null; | |
| 83 | 92 | |
| 84 | 93 | /** |
| 85 | 94 | * Whether `viewer` finds no account at `user`'s name: a suspended or removed |
| @@ -136,7 +145,13 @@ export type AccountOrigin = | ||
| 136 | 145 | * `invite` is the token of an invite link (`core/invites.ts`): while |
| 137 | 146 | * signup is `invite` it opens the door once, spent with the account. |
| 138 | 147 | */ |
| 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 | } | |
| 140 | 155 | | { origin: "edge" } |
| 141 | 156 | | { origin: Exclude<ExemptOrigin, AdminOrigin> } |
| 142 | 157 | | ({ origin: AdminOrigin } & AdminFields); |
| @@ -162,13 +177,19 @@ export type CreateUserInput = CreateUserFields & AccountOrigin; | ||
| 162 | 177 | * (`inviteNeeded` says when it must be spent). |
| 163 | 178 | */ |
| 164 | 179 | 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)); | |
| 165 | 182 | if (!inviteNeeded(db)) return false; |
| 166 | 183 | return !(getSignup(db) === "invite" && invite !== undefined && inviteOpen(db, invite)); |
| 167 | 184 | } |
| 168 | 185 | |
| 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 | */ | |
| 170 | 190 | function inviteNeeded(db: Database): boolean { |
| 171 | return getSignup(db) !== "open"; | |
| 191 | const mode = getSignup(db); | |
| 192 | return mode === "invite" || mode === "closed"; | |
| 172 | 193 | } |
| 173 | 194 | |
| 174 | 195 | interface CreateUserFields { |
| @@ -230,9 +251,9 @@ export function discardFreshUser(db: Database, userId: string): void { | ||
| 230 | 251 | */ |
| 231 | 252 | type Admission = |
| 232 | 253 | | { readonly admitted: false } |
| 233 | | { readonly admitted: true; readonly spend: string | undefined }; | |
| 254 | | { readonly admitted: true; readonly spend: string | undefined; readonly request: "waiting" | "none" }; | |
| 234 | 255 | |
| 235 | const ADMITTED: Admission = { admitted: true, spend: undefined }; | |
| 256 | const ADMITTED: Admission = { admitted: true, spend: undefined, request: "none" }; | |
| 236 | 257 | const REFUSED: Admission = { admitted: false }; |
| 237 | 258 | |
outdated
HIGH: in |
||
| 238 | 259 | function admission(db: Database, input: AccountOrigin): Admission { |
| @@ -240,10 +261,16 @@ function admission(db: Database, input: AccountOrigin): Admission { | ||
| 240 | 261 | case "signup": |
| 241 | 262 | case "passkey": |
| 242 | 263 | 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 | } | |
| 243 | 270 | if (!inviteNeeded(db)) return ADMITTED; |
| 244 | 271 | return newAccountsClosed(db, input.invite) || input.invite === undefined |
| 245 | 272 | ? REFUSED |
| 246 | : { admitted: true, spend: input.invite }; | |
| 273 | : { admitted: true, spend: input.invite, request: "none" }; | |
| 247 | 274 | case "edge": |
| 248 | 275 | return newAccountsClosed(db) ? REFUSED : ADMITTED; |
| 249 | 276 | case "claim": |
| @@ -343,6 +370,15 @@ export async function createUser( | ||
| 343 | 370 | admin.adminIfFirst ? 1 : 0, |
| 344 | 371 | agent ? 1 : 0, |
| 345 | 372 | ); |
| 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 | } | |
| 346 | 382 | return true; |
| 347 | 383 | }); |
| 348 | 384 | try { |
| @@ -480,7 +516,9 @@ export function ensureEdgeUser(db: Database, login: string): User | undefined { | ||
| 480 | 516 | /** Why a password was not accepted: wrong, or tried too often to check. */ |
| 481 | 517 | export type PasswordRefusal = |
| 482 | 518 | | { 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 }; | |
| 484 | 522 | |
| 485 | 523 | /** One password attempt: who it names, the secret, and the peer that sent it. */ |
| 486 | 524 | export interface PasswordAttempt { |
| @@ -515,6 +553,10 @@ export async function verifyPasswordThrottled( | ||
| 515 | 553 | admission.succeeded(); |
| 516 | 554 | return err({ reason: "throttled", retryAfter: GATE_RETRY_AFTER }); |
| 517 | 555 | } |
| 556 | if (!checked.ok && checked.error === "waiting") { | |
| 557 | admission.succeeded(); | |
| 558 | return err({ reason: "waiting", username: attempt.username }); | |
| 559 | } | |
| 518 | 560 | if (!checked.ok) return err({ reason: "bad_credentials" }); |
| 519 | 561 | admission.succeeded(); |
| 520 | 562 | return checked; |
| @@ -538,7 +580,7 @@ async function checkPassword( | ||
| 538 | 580 | db: Database, |
| 539 | 581 | username: string, |
| 540 | 582 | password: string, |
| 541 | ): Promise<Result<User, "bad_credentials">> { | |
| 583 | ): Promise<Result<User, "bad_credentials" | "waiting">> { | |
| 542 | 584 | const row = db |
| 543 | 585 | .query< |
| 544 | 586 | UserRow, |
| @@ -555,6 +597,8 @@ async function checkPassword( | ||
| 555 | 597 | if (!valid || !row || row.kind === "org" || row.is_agent === 1) |
| 556 | 598 | return err("bad_credentials"); |
| 557 | 599 | 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"); | |
| 558 | 602 | return isActive(user) ? ok(user) : err("bad_credentials"); |
| 559 | 603 | } |
| 560 | 604 |
src/crew/notify.ts +2
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | |
| 23 | 23 | import { blockedLines } from "../actions/egress_refused.ts"; |
| 24 | 24 | import { newAccountLines } from "../actions/accounts.ts"; |
| 25 | import { requestLines } from "../actions/requests.ts"; | |
| 25 | 26 | import { mailCeilingLines } from "../actions/mail.ts"; |
| 26 | 27 | import { forgetOnboarding, onboardedLines } from "../apps/onboarding.ts"; |
| 27 | 28 | import type { Database } from "bun:sqlite"; |
| @@ -501,6 +502,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 501 | 502 | // to itself. |
| 502 | 503 | const asks: { line: string; items: readonly string[]; link?: string }[] = [ |
| 503 | 504 | ...askLinesFor(app.db, user).map((a) => ({ line: a.line, items: a.items })), |
| 505 | ...requestLines(app.db, user), | |
| 504 | 506 | ...newAccountLines(app.db, user, now()), |
| 505 | 507 | ...landedLines(app.db, user, now()), |
| 506 | 508 | ...mailCeilingLines(app, user, now()), |
src/http/api.ts +8 −2
| @@ -127,6 +127,7 @@ import { | ||
| 127 | 127 | PASSKEY_REQUIRED_MESSAGE, |
| 128 | 128 | signUp, |
| 129 | 129 | SIGNUP_CLOSED_MESSAGE, |
| 130 | REQUEST_IN_MESSAGE, | |
| 130 | 131 | } from "../actions/users.ts"; |
| 131 | 132 | import { OFFSET_LIMIT_MAX, offsetPage, offsetQuery } from "./offset_page.ts"; |
| 132 | 133 | import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor, validEventsKind } from "../core/events_page.ts"; |
| @@ -193,7 +194,7 @@ import { isPinned } from "../core/pins.ts"; | ||
| 193 | 194 | import { diffCommit } from "../git/diff.ts"; |
| 194 | 195 | import { runGit } from "../git/env.ts"; |
| 195 | 196 | import { appLimitWords, appsOwnedBy } from "../actions/app_limit.ts"; |
| 196 | import { getQuota } from "../settings/quotas.ts"; | |
| 197 | import { getQuota, quotaOf } from "../settings/quotas.ts"; | |
| 197 | 198 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; |
| 198 | 199 | import { compareBranches, listBranchRows } from "../git/branches.ts"; |
| 199 | 200 | import { |
| @@ -1099,7 +1100,7 @@ export function positiveInt(raw: string | undefined): number | undefined { | ||
| 1099 | 1100 | /** 403 naming the limit and the count, for an account at its app limit. */ |
| 1100 | 1101 | export function appLimitResponse(ctx: Ctx, ownerName: string): Response { |
| 1101 | 1102 | 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"); | |
| 1103 | 1104 | return json( |
| 1104 | 1105 | { |
| 1105 | 1106 | error: "app_limit", |
| @@ -1192,6 +1193,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1192 | 1193 | email: str(input.email), |
| 1193 | 1194 | password: str(input.password), |
| 1194 | 1195 | humanToken: str(input.turnstile_token), |
| 1196 | // 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), | |
| 1195 | 1199 | }, |
| 1196 | 1200 | { |
| 1197 | 1201 | slot, |
| @@ -1206,6 +1210,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1206 | 1210 | if (!created.ok && created.error === "human_check") |
| 1207 | 1211 | return apiError("human_check", 422, HUMAN_CHECK_MESSAGE); |
| 1208 | 1212 | if (!created.ok) return json({ error: created.error }, 422); |
| 1213 | // 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); | |
| 1209 | 1215 | return json(publicUser(created.value), 201); |
| 1210 | 1216 | }); |
| 1211 | 1217 |
src/http/api/admin.ts +19
| @@ -21,6 +21,7 @@ import { setAccountCeilings, setAccountGrant } from "../../actions/model_grant.t | ||
| 21 | 21 | import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts"; |
| 22 | 22 | import { TARGETS } from "../../cloud/targets.ts"; |
| 23 | 23 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; |
| 24 | import { allowRequest, declineRequest, listRequests } from "../../actions/requests.ts"; | |
| 24 | 25 | import { setRole } from "../../actions/roles.ts"; |
| 25 | 26 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; |
| 26 | 27 | import { changeForgeSource, changeSignup, readForgeSource, readSignup } from "../../actions/signup.ts"; |
| @@ -435,6 +436,24 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 435 | 436 | : notFound(); |
| 436 | 437 | }); |
| 437 | 438 | |
| 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 | ||
| 438 | 457 | // Every person and their role. |
| 439 | 458 | router.add("GET", "/api/v1/admin/people", (ctx) => { |
| 440 | 459 | 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 @@ | ||
| 1 | 1 | // Generated by `bun scripts/mcp-tools.ts` from the files beside it; do not edit. |
| 2 | 2 | // One line per tool, sorted: a new tool is a new file and one new line here. |
| 3 | 3 | export { tool as access } from "./access.ts"; |
| 4 | export { tool as account_requests } from "./account_requests.ts"; | |
| 4 | 5 | export { tool as account_role } from "./account_role.ts"; |
| 5 | 6 | export { tool as account_suspend } from "./account_suspend.ts"; |
| 6 | 7 | export { tool as account_verify } from "./account_verify.ts"; |
src/http/server.ts +2
| @@ -140,6 +140,7 @@ import { | ||
| 140 | 140 | signinPage, |
| 141 | 141 | signinSubmit, |
| 142 | 142 | signupPage, |
| 143 | requestedPage, | |
| 143 | 144 | signupSubmit, |
| 144 | 145 | verifyPage, |
| 145 | 146 | themeSubmit, |
| @@ -860,6 +861,7 @@ export function buildRouter(): Router<Handler> { | ||
| 860 | 861 | router.add("GET", "/signin/parent", parentStart); |
| 861 | 862 | router.add("GET", "/signin/parent/callback", parentCallback); |
| 862 | 863 | router.add("GET", "/signup", signupPage); |
| 864 | router.add("GET", "/signup/requested", requestedPage); | |
| 863 | 865 | router.add("GET", "/verify", verifyPage); |
| 864 | 866 | router.add("POST", "/signup", signupSubmit); |
| 865 | 867 | router.add("POST", "/logout", logoutSubmit); |
src/http/settings_routes.ts +3
| @@ -25,6 +25,7 @@ import { securityControlsPage } from "../web/pages/security_controls.ts"; | ||
| 25 | 25 | import { securityPage, securityShieldSet } from "../web/pages/security.ts"; |
| 26 | 26 | import { trafficPage } from "../web/pages/traffic.ts"; |
| 27 | 27 | import { sourcesPage } from "../web/pages/sources.ts"; |
| 28 | import { requestAct } from "../web/requests_card.ts"; | |
| 28 | 29 | import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts"; |
| 29 | 30 | import { |
| 30 | 31 | accountAct, |
| @@ -239,6 +240,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 239 | 240 | router.add("POST", "/settings/backups/mirror/clear", backupsMirrorClear); |
| 240 | 241 | router.add("POST", "/settings/backups/mirror/now", backupsMirrorNow); |
| 241 | 242 | 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")); | |
| 242 | 245 | router.add("GET", "/settings/people", peoplePage); |
| 243 | 246 | router.add("POST", "/settings/people/:username/role", roleAct); |
| 244 | 247 | router.add("GET", "/settings/model", modelPage); |
src/settings/quotas.ts +25 −2
| @@ -27,6 +27,29 @@ export function isQuotaKey(key: string): key is QuotaKey { | ||
| 27 | 27 | return (QUOTA_KEYS as readonly string[]).includes(key); |
| 28 | 28 | } |
| 29 | 29 | |
| 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 | ||
| 30 | 53 | const storeKey = (key: QuotaKey): string => `quota.${key}`; |
| 31 | 54 | |
| 32 | 55 | /** 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 { | ||
| 75 | 98 | |
| 76 | 99 | /** The sentence a refused creation says, or nothing when there is room. */ |
| 77 | 100 | export function repositoryLimitSentence(db: Database, ownerId: string): string | undefined { |
| 78 | const limit = getQuota(db, "repositories"); | |
| 101 | const limit = quotaOf(db, "repositories", ownerId); | |
| 79 | 102 | if (limit === null) return undefined; |
| 80 | 103 | const owned = repositoriesOwned(db, ownerId); |
| 81 | 104 | return owned >= limit ? `${owned} of ${limit} repositories` : undefined; |
| @@ -102,7 +125,7 @@ export function runningLimitSentence( | ||
| 102 | 125 | ownerId: string, |
| 103 | 126 | held: () => number, |
| 104 | 127 | ): string | undefined { |
| 105 | const limit = getQuota(db, key); | |
| 128 | const limit = quotaOf(db, key, ownerId); | |
| 106 | 129 | if (limit === null || exempt(db, ownerId)) return undefined; |
| 107 | 130 | const n = held(); |
| 108 | 131 | return n >= limit ? `${n} of ${limit} ${noun}` : undefined; |
src/settings/signup.ts +11 −3
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * Who may make an account: `open`, `invite` or `closed`. Stored in | |
| 2 | * Who may make an account: `open`, `request`, `invite` or `closed`. Stored in | |
| 3 | 3 | * `forge_setting` under `signup`, and every change is kept in |
| 4 | 4 | * `forge_setting_change` (who, when, from what to what). Default `open` when nothing is stored; a new forge stores `invite` (`seedSignup`). |
| 5 | 5 | * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask |
| @@ -11,7 +11,15 @@ import type { Database } from "bun:sqlite"; | ||
| 11 | 11 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 12 | 12 | import { writeSetting } from "./limits.ts"; |
| 13 | 13 | |
| 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 | }; | |
| 15 | 23 | export type SignupMode = (typeof SIGNUP_MODES)[number]; |
| 16 | 24 | export const SIGNUP_DEFAULT: SignupMode = "open"; |
| 17 | 25 | |
| @@ -66,7 +74,7 @@ export function signupFromEnv(raw: string | undefined): Result<SignupMode | null | ||
| 66 | 74 | const parsed = parseSignup(s); |
| 67 | 75 | return parsed.ok |
| 68 | 76 | ? 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)}"`); | |
| 70 | 78 | } |
| 71 | 79 | |
| 72 | 80 | export interface SignupChange { |
src/tui/screens/entries.ts +2
| @@ -61,6 +61,7 @@ import { entry as people } from "./people.ts"; | ||
| 61 | 61 | import { entry as pinned } from "./pinned.ts"; |
| 62 | 62 | import { entry as profile } from "./profile.ts"; |
| 63 | 63 | import { entry as quotas } from "./quotas.ts"; |
| 64 | import { entry as requests } from "./requests.ts"; | |
| 64 | 65 | import { entry as resilience } from "./resilience.ts"; |
| 65 | 66 | import { entry as scorecard } from "./scorecard.ts"; |
| 66 | 67 | import { entry as weekcard } from "./weekcard.ts"; |
| @@ -135,6 +136,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 135 | 136 | agents, |
| 136 | 137 | archived, |
| 137 | 138 | invites, |
| 139 | requests, | |
| 138 | 140 | bulk, |
| 139 | 141 | subscriptions, |
| 140 | 142 | 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 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | 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"; | |
| 13 | 13 | import { SignupSlot, waitText } from "../../actions/signup-window.ts"; |
| 14 | 14 | import { hasAnyUser } from "../../core/users.ts"; |
| 15 | 15 | import { getSignup } from "../../settings/signup.ts"; |
| @@ -117,6 +117,12 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 117 | 117 | else error = errorText(created.error); |
| 118 | 118 | return; |
| 119 | 119 | } |
| 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 | } | |
| 120 | 126 | const adopted = f.adopt(created.value); |
| 121 | 127 | if (!adopted.ok) { |
| 122 | 128 | error = errorText(adopted.error); |
src/tui/session.ts +1 −1
| @@ -133,7 +133,7 @@ export class Session { | ||
| 133 | 133 | password, |
| 134 | 134 | source: this.identity.remoteAddress, |
| 135 | 135 | }); |
| 136 | if (!verified.ok) return err(verified.error.reason); | |
| 136 | if (!verified.ok) return err(verified.error.reason === "waiting" ? "bad_credentials" : verified.error.reason); | |
| 137 | 137 | return this.adopt(verified.value); |
| 138 | 138 | } |
| 139 | 139 |
src/web/pages/accounts.ts +3 −1
| @@ -45,6 +45,7 @@ import { sessionsCard } from "./account.ts"; | ||
| 45 | 45 | import { ceilingCard, grantCard } from "./model.ts"; |
| 46 | 46 | import { accountSections } from "./origins.ts"; |
| 47 | 47 | import { markOf } from "../../core/marks.ts"; |
| 48 | import { requestsCard } from "../requests_card.ts"; | |
| 48 | 49 | |
| 49 | 50 | function shell(ctx: Ctx, body: Html, status = 200): Response { |
| 50 | 51 | return page( |
| @@ -65,6 +66,7 @@ function shell(ctx: Ctx, body: Html, status = 200): Response { | ||
| 65 | 66 | function standing(a: AccountRow): Html { |
| 66 | 67 | if (a.removedAt !== null) return badge("Removed", { variant: "outline" }); |
| 67 | 68 | if (a.suspendedAt !== null) return badge("Suspended", { tone: "danger" }); |
| 69 | if (a.waiting) return badge("Waiting", { variant: "outline" }); | |
| 68 | 70 | if (!a.verified) return badge("Email not verified", { variant: "outline" }); |
| 69 | 71 | return html``; |
| 70 | 72 | } |
| @@ -198,7 +200,7 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { | ||
| 198 | 200 | const rendering = performance.now(); |
| 199 | 201 | const response = shell( |
| 200 | 202 | 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"> | |
| 202 | 204 | <h2>Who is on this forge</h2> |
| 203 | 205 | <p class="muted">Newest first.</p> |
| 204 | 206 | <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 | ||
| 12 | 12 | import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts"; |
| 13 | 13 | import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts"; |
| 14 | 14 | import { changeSignup, readSignup } from "../../actions/signup.ts"; |
| 15 | import { SIGNUP_MODES } from "../../settings/signup.ts"; | |
| 15 | import { SIGNUP_MEANS, SIGNUP_MODES } from "../../settings/signup.ts"; | |
| 16 | 16 | import { QUOTA_KEYS } from "../../settings/quotas.ts"; |
| 17 | 17 | import type { Ctx } from "../../http/context.ts"; |
| 18 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| @@ -122,16 +122,19 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 122 | 122 | }), |
| 123 | 123 | ); |
| 124 | 124 | const signupRow = html`<div class="status-row"> |
| 125 | <dt>Signup</dt> | |
| 125 | <dt>New accounts</dt> | |
| 126 | 126 | <dd> |
| 127 | 127 | <form class="row-fields" method="post" action="/admin/signup"> |
| 128 | <select class="input mono" name="signup" aria-label="Who may sign up"> | |
| 128 | <select class="input mono" name="signup" aria-label="New accounts"> | |
| 129 | 129 | ${SIGNUP_MODES.map( |
| 130 | 130 | (m) => html`<option value="${m}" ${signup.ok && signup.value.signup === m ? "selected" : ""}>${m}</option>`, |
| 131 | 131 | )} |
| 132 | 132 | </select> |
| 133 | 133 | ${btn("Save", { size: "sm", variant: "primary", label: "Save signup" })} |
| 134 | 134 | </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. |
||
| 135 | 138 | </dd> |
| 136 | 139 | <dd class="status-act"></dd> |
| 137 | 140 | </div>`; |
| @@ -280,7 +283,7 @@ export async function signupSet(ctx: Ctx): Promise<Response> { | ||
| 280 | 283 | "/admin/limits", |
| 281 | 284 | changed.ok |
| 282 | 285 | ? { 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." }, | |
| 284 | 287 | ); |
| 285 | 288 | } |
| 286 | 289 |
src/web/pages/passkeys.ts +6 −1
| @@ -20,6 +20,7 @@ import { | ||
| 20 | 20 | import { linkEdgeLogin } from "../../core/edge_links.ts"; |
| 21 | 21 | import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts"; |
| 22 | 22 | import { RateLimiter } from "../../core/ratelimit.ts"; |
| 23 | import { requestOf } from "../../core/requests.ts"; | |
| 23 | 24 | import { createSession } from "../../core/sessions.ts"; |
| 24 | 25 | import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts"; |
| 25 | 26 | import { recoveryAccount, requestRecovery, spendRecoveryLink } from "../../actions/verify.ts"; |
| @@ -197,7 +198,8 @@ export async function signinPasskey(ctx: Ctx): Promise<Response> { | ||
| 197 | 198 | const found = findPasskey(ctx.app.db, str(data.id)); |
| 198 | 199 | if (!found) return no(); |
| 199 | 200 | 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(); | |
| 201 | 203 | const verified = await verifyAssertion( |
| 202 | 204 | { |
| 203 | 205 | clientDataJSON: str(data.clientDataJSON), |
| @@ -209,6 +211,9 @@ export async function signinPasskey(ctx: Ctx): Promise<Response> { | ||
| 209 | 211 | ); |
| 210 | 212 | if (!verified.ok) return no(); |
| 211 | 213 | 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); | |
| 212 | 217 | const session = createSession(ctx.app.db, user.id); |
| 213 | 218 | return json( |
| 214 | 219 | { 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> { | ||
| 102 | 102 | if (typeof slot === "number") return tooMany(slot); |
| 103 | 103 | const created = await signUpWithPasskey( |
| 104 | 104 | ctx.app, |
| 105 | { username, email, passkey: made, passkeyName: "Passkey", humanToken: str(data.humanToken) }, | |
| 105 | { username, email, passkey: made, passkeyName: "Passkey", humanToken: str(data.humanToken), note: str(data.note) }, | |
| 106 | 106 | { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request) }, |
| 107 | 107 | ); |
| 108 | 108 | if (!created.ok) |
| 109 | 109 | return refuse(created.error === "human_check" ? 422 : created.error === "signup_closed" ? 403 : 409, WORDS[created.error] ?? "Sign-up did not work. Try again."); |
| 110 | // A request 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); | |
| 110 | 112 | const session = createSession(ctx.app.db, created.value.id); |
| 111 | 113 | return json( |
| 112 | 114 | { ok: true, next: safeNext(ctx.url.searchParams.get("next")) ?? "/" }, |
src/web/pages/site.ts +53 −3
| @@ -18,12 +18,14 @@ import { verifyEmail } from "../../actions/verify.ts"; | ||
| 18 | 18 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 19 | 19 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts"; |
| 20 | 20 | import { REPO_INIT_FAILED, REPO_NAME_RULES } from "../../actions/repo_words.ts"; |
| 21 | import { NOTE_MAX, requestOf } from "../../core/requests.ts"; | |
| 22 | import { DECLINED_MESSAGE, REQUEST_IN_MESSAGE, STILL_WAITING_MESSAGE } from "../../actions/users.ts"; | |
| 21 | 23 | import { HUMAN_CHECK_MESSAGE, INVITE_SPENT_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; |
| 22 | 24 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 23 | 25 | import { listPins } from "../../core/pins.ts"; |
| 24 | 26 | import { getReposByIds } from "../../core/repos.ts"; |
| 25 | 27 | 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"; | |
| 27 | 29 | import { isThemeName } from "../../design/themes.ts"; |
| 28 | 30 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; |
| 29 | 31 | import { studioDoor, studioHome } from "../studio/home.ts"; |
| @@ -231,6 +233,7 @@ interface AuthErrors { | ||
| 231 | 233 | /** What `/signin` says about new accounts in each signup mode; open says nothing. */ |
| 232 | 234 | const SIGNUP_SENTENCES: Record<SignupMode, string | undefined> = { |
| 233 | 235 | open: undefined, |
| 236 | request: "New accounts here start after someone who runs the forge allows them.", | |
| 234 | 237 | invite: "This forge is invite-only: new accounts need an invite link.", |
| 235 | 238 | closed: "This forge is closed to new accounts.", |
| 236 | 239 | }; |
| @@ -381,6 +384,17 @@ function authForm( | ||
| 381 | 384 | <button type="button" id="passkey-signup" class="btn" data-variant="outline">Create account with a passkey</button> |
| 382 | 385 | </div>`, |
| 383 | 386 | )} |
| 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 | )} | |
| 384 | 398 | ${when( |
| 385 | 399 | errors.human, |
| 386 | 400 | (message) => html`<p class="error mb" role="alert">${message}</p>`, |
| @@ -398,7 +412,7 @@ function authForm( | ||
| 398 | 412 | ? html`<button type="button" id="passkey-signup" class="btn" data-variant="primary">Create account with a passkey</button>` |
| 399 | 413 | : kind === "signin" |
| 400 | 414 | ? 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" })} | |
| 402 | 416 | <span class="spacer"></span> |
| 403 | 417 | ${kind === "signin" |
| 404 | 418 | ? // A closed forge has no door to point at: the sentence above says so. |
| @@ -423,7 +437,7 @@ function authForm( | ||
| 423 | 437 | const claimable = |
| 424 | 438 | existsSync(join(ctx.app.config.dataDir, "parent.json")) && unowned; |
| 425 | 439 | 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")} | |
| 427 | 441 | ${when( |
| 428 | 442 | kind === "signup", |
| 429 | 443 | () => 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> { | ||
| 555 | 569 | response.headers.set("retry-after", String(wait)); |
| 556 | 570 | return response; |
| 557 | 571 | } |
| 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 | } | |
| 558 | 577 | if (!verified.ok) { |
| 559 | 578 | return page( |
| 560 | 579 | { |
| @@ -580,6 +599,34 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> { | ||
| 580 | 599 | ); |
| 581 | 600 | } |
| 582 | 601 | |
| 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 | ||
| 583 | 630 | // GET /verify?token= — the link in a verification email. |
| 584 | 631 | export function verifyPage(ctx: Ctx): Response { |
| 585 | 632 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -673,6 +720,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 673 | 720 | email, |
| 674 | 721 | password: field(form, "password"), |
| 675 | 722 | humanToken: field(form, "cf-turnstile-response"), |
| 723 | note: field(form, "note"), | |
| 676 | 724 | }, |
| 677 | 725 | { slot, invite: inviteOf(ctx), address: clientAddress(ctx.request) }, |
| 678 | 726 | ); |
| @@ -702,6 +750,8 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 702 | 750 | 422, |
| 703 | 751 | ); |
| 704 | 752 | } |
| 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"); | |
| 705 | 755 | const session = createSession(ctx.app.db, created.value.id); |
| 706 | 756 | return redirect( |
| 707 | 757 | safeNext(ctx.url.searchParams.get("next")) ?? "/", |
src/web/passkey-client.ts +1
| @@ -131,6 +131,7 @@ export const passkeyJs = /* js */ ` | ||
| 131 | 131 | return post('/signup/passkey' + location.search, { |
| 132 | 132 | username: who.username, |
| 133 | 133 | email: who.email, |
| 134 | note: val('note'), | |
| 134 | 135 | humanToken: val('cf-turnstile-response'), |
| 135 | 136 | challenge: o.challenge, |
| 136 | 137 | clientDataJSON: text(cred.response.clientDataJSON), |
src/web/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 | ||
| 411 | 411 | "", |
| 412 | 412 | "If you are an assistant helping a person: say plainly what you can and cannot do from where you are. If you cannot reach this forge or hold an account on it, give the person these steps to do by hand.", |
| 413 | 413 | "", |
| 414 | `- Help build a public app here: each one is a repository at ${o}/{owner}/{name}. A person makes an account at ${o}/signup, then follows "Offer a change" below. If /signup answers 403, new accounts here are by invitation for now: send a suggestion through "Report a problem" below, or work from a forge of your own.`, | |
| 414 | `- Help build a public app here: each one is a repository at ${o}/{owner}/{name}. A person makes an account at ${o}/signup, then follows "Offer a change" below. 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.`, | |
| 415 | 415 | `- Get your own forge: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, free within limits while hosting is not sold: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`, |
| 416 | 416 | `- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: ${UPSTREAM_REPO}/raw/main/docs/ORIGINS.md`, |
| 417 | 417 | `- Connect an assistant that can use tools: add a custom connector with the URL ${mcp.url}. It signs in with OAuth; only the report tool works without an account.`, |
src/web/studio/home.ts +10 −2
| @@ -20,7 +20,9 @@ import { | ||
| 20 | 20 | } from "../../actions/front.ts"; |
| 21 | 21 | import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts"; |
| 22 | 22 | 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"; | |
| 24 | 26 | import { getSignup } from "../../settings/signup.ts"; |
| 25 | 27 | import { appsForRepos } from "../../core/apps.ts"; |
| 26 | 28 | import { WEIGHT_MAX, WEIGHT_NAMES } from "../../core/front.ts"; |
| @@ -351,8 +353,14 @@ export async function studioHome( | ||
| 351 | 353 | isNavigation(ctx.request) ? noteVisit(ctx.app.db, user.id, visitedAt) : lastVisit(ctx.app.db, user.id), |
| 352 | 354 | ); |
| 353 | 355 | const since = timePhase(ctx.request, "since", () => sinceYouWere(ctx.app.db, user, before, visitedAt)); |
| 354 | const needsYou = html`${waiting}${held}${alarms}`; | |
| 356 | // 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}`; | |
| 355 | 362 | 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} | |
| 356 | 364 | ${tellBox(ctx, user)} |
| 357 | 365 | ${ask} ${address} |
| 358 | 366 | ${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", () => { | ||
| 38 | 38 | "hidden is true or false", |
| 39 | 39 | "op must be pin, unpin or move", |
| 40 | 40 | "order is oldest or newest", |
| 41 | "signup is open, invite or closed", | |
| 41 | "signup is open, request, invite or closed", | |
| 42 | 42 | "visibility is public or private", |
| 43 | 43 | ]); |
| 44 | 44 | }); |
test/conversation-confirm.test.ts +1 −1
| @@ -120,7 +120,7 @@ describe("the one list of what asks first", () => { | ||
| 120 | 120 | // Writes that do not ask first, on purpose: each reads, plans, or only changes the caller's own |
| 121 | 121 | // settings and can be put back. A new `writes: true` tool is on neither list until someone decides. |
| 122 | 122 | 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", | |
| 124 | 124 | "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait", |
| 125 | 125 | "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner", |
| 126 | 126 | "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", () => { | ||
| 265 | 265 | "invites", |
| 266 | 266 | "account_role", |
| 267 | 267 | "account_suspend", |
| 268 | "account_requests", | |
| 268 | 269 | "flag_winner", |
| 269 | 270 | "restore_drill", |
| 270 | 271 | "model_ceiling", |
test/palette-roster.test.ts +1
| @@ -145,6 +145,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 145 | 145 | ["teams", "Crew teams", "each team's charter, dial and members"], |
| 146 | 146 | ["agents", "Crew agents", "each agent's model, budget and brief"], |
| 147 | 147 | ["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"], | |
| 148 | 149 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 149 | 150 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 150 | 151 | ["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 <b>shop</b> 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)", () => { | ||
| 377 | 377 | test("two pulls' migrations land in either order and the schema ends the same", () => { |
| 378 | 378 | // Two builders, each adding one migration at the second they wrote it: |
| 379 | 379 | // 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)" }; | |
| 382 | 382 | const both = listIn(directory({ ...a, ...b })); |
| 383 | 383 | expect(both.length).toBe(migrations.length + 2); |
| 384 | 384 | |
| @@ -397,12 +397,12 @@ describe("a migration is a file named by a sortable id (#1837)", () => { | ||
| 397 | 397 | for (const path of [aFirst, bFirst, together]) { |
| 398 | 398 | const db = new Database(path, { readonly: true, strict: true }); |
| 399 | 399 | 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") | |
| 401 | 401 | .all() |
| 402 | 402 | .map((row) => row.name); |
| 403 | 403 | 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"); | |
| 406 | 406 | } |
| 407 | 407 | const db = openDb(together, both); |
| 408 | 408 | 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 () => { | ||
| 73 | 73 | const h = boot("alice"); |
| 74 | 74 | await h.open(); |
| 75 | 75 | 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"); | |
| 78 | 78 | expect(h.text()).toContain("from open to closed"); |
| 79 | 79 | await h.press("esc"); |
| 80 | 80 | expect(getSignup(forge.db)).toBe("open"); |
| 81 | 81 | // esc on the picker too |
| 82 | 82 | await h.press("s", "esc"); |
| 83 | 83 | expect(getSignup(forge.db)).toBe("open"); |
| 84 | await h.press("s", "j", "j", "enter", "enter"); | |
| 84 | await h.press("s", "j", "j", "j", "enter", "enter"); | |
| 85 | 85 | expect(getSignup(forge.db)).toBe("closed"); |
| 86 | 86 | expect(signupClosed({ db: forge.db } as never)).toBe(true); |
| 87 | 87 | expect(h.text()).toContain("signup: closed"); |
One notifier line per waiting request lets a flood spam the admin's phone. Collapse to a single count line past a few.