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

An unknown edge sign-in creates no account and goes to the forge's own signup (with the createUser door list updated on purpose) #2779

Merged opened this

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

Merged

scout/forge:scout/2763-an-unknown-edge-sign-in-creates-no-accou → main 7 commits fork 11 threads · 11 unresolved

  • Merged. scout/2763-an-unknown-edge-sign-in-creates-no-accou was merged into main as 08ae5ca.

Review

Sign in to review this change.
22 files changed +265 −98
README.md +6 −1
@@ -351,7 +351,12 @@ adds `FORGE_URL` (the canonical public base URL, needed for OAuth callbacks),
351351 `FORGE_ORIGIN_ALLOW` (hosts an operator vouches for on a LAN). Behind a
352352 platform edge that signs people in for every app it serves, `FORGE_EDGE_TOKEN`
353353 is the edge's secret: a request carrying it and `X-Plat-User` is that person,
354 signed in by the platform, with an account made on first sight. Setting
354 signed in by the platform if an account of that name exists. An unknown login
355 makes none: it is sent to `/signup`, where the `signup` setting decides, and
356 the name there is pinned to the login the edge vouched for (a visitor the edge
357 has not vouched for is sent through it first), so a platform user's name can
358 never be taken by someone else. The terminal cannot see `X-Plat-User`, so
359 behind an edge sign-up is on the web page only and the terminal says so. Setting
355360 `signin.edge` to `off` (Settings → Capacity → "Sign in through the platform
356361 edge", boot default `FORGE_SIGNIN_EDGE`, also `/api/v1/admin/capacity` and the
357362 MCP `capacity` tool) makes the forge ignore `X-Plat-User` even when the secret
src/core/users.ts +8 −24
@@ -6,7 +6,6 @@
66 import { markUnproven, markVerified } from "./verify.ts";
77 import type { Database } from "bun:sqlite";
88 import { isSafeName } from "../git/guard.ts";
9 import { noreplyAddress } from "./commit_identity.ts";
109 import { reservedName } from "./reserved.ts";
1110 import { id } from "../kernel/id.ts";
1211 import { err, ok, type Result } from "../kernel/result.ts";
@@ -452,31 +451,16 @@ export function getUserByName(
452451
453452 /**
454453 * The person behind an identity the platform edge vouched for: found by
455 * name, or made on first sight — with no password, because the edge is how
456 * they sign in, and a noreply address until they set one. Like a sign-up,
457 * it is never the admin, first or not. A name that is an
458 * organization or a crew member here is nobody's login.
454 * name, never made. The edge vouches for a login, not for a right to an
455 * account here, so an unknown login is signed out and goes to /signup, where
456 * the `signup` setting decides. Accounts the edge made before still sign in.
457 * A name that is an organization or a crew member here is nobody's login.
459458 */

Nit: the function never makes an account now, so the name ensure is misleading. Something like edgeUser or findEdgeUser would match what the doc comment says.

460 export async function ensureEdgeUser(
461 db: Database,
462 login: string,
463 host: string,
464 ): Promise<User | undefined> {
459 export function ensureEdgeUser(db: Database, login: string): User | undefined {
465460 const existing = getUserByName(db, login);
466 if (existing)
467 return existing.kind === "user" && isActive(existing) ? existing : undefined;
468 // The edge vouches for a login, not for its right to a name: createUser
469 // refuses a reserved one, so a platform account called after the crew or
470 // the owner is not let in under it.
471 const made = await createUser(db, {
472 username: login,
473 email: noreplyAddress(login, host),
474 origin: "edge",
475 });
476 if (!made.ok) return undefined;
477 // A newcomer through the edge starts unproven like one through /signup
478 // (its placeholder is no address at all).
479 return made.value;
461 return existing && existing.kind === "user" && isActive(existing)
462 ? existing
463 : undefined;
480464 }
481465
482466 /** Why a password was not accepted: wrong, or tried too often to check. */
src/http/api.ts +6 −3
@@ -369,7 +369,7 @@ import {
369369 undoFleetOp,
370370 type FleetError,
371371 } from "../actions/fleet.ts";
372 import { sessionCookie, signupSlot } from "../web/pages/site.ts";
372 import { EDGE_NAME_MESSAGE, pinnedTo, sessionCookie, signupSlot } from "../web/pages/site.ts";
373373 import {
374374 FIRST_ADMIN_TOKEN_MAX_S,
375375 FIRST_ADMIN_TOKEN_MIN_S,
@@ -1223,6 +1223,10 @@ export function apiRoutes(router: Router<Handler>): void {
12231223 const input = await boundedBody(ctx.request, STRANGER_MAX);
12241224 if (input === "too_large")
outdated

pinnedTo decides closed-ness with inviteOf(ctx) (the query), but this route passes signUp the invite from the body (input.invite). With signup = invite and the invite sent only in the body, pinnedTo sees a closed door, returns true, and the pin is skipped, so any name can be taken. Pass the body's invite into the pin. Tracked in #2828.

12251225 return apiError("too_large", 413, `at most ${STRANGER_MAX} bytes`);
1226 // The token of an invite link (`core/invites.ts`), when signup is `invite`.
outdated

High: pinnedTo reads the invite from the URL query, but signUp below takes it from input.invite in the body. With signup = invite and the invite only in the body, pinnedTo treats signup as closed, returns true and skips the name check. Then signUp accepts the body invite and creates any name. Compute const invite = str(input.invite) || undefined once and pass it to both: pinnedTo(ctx, username, invite) and signUp(..., { invite }).

1227 const invite = str(input.invite) === "" ? undefined : str(input.invite);
1228 if (!pinnedTo(ctx, str(input.username).trim(), invite))
1229 return apiError("forbidden", 403, EDGE_NAME_MESSAGE);
12261230 const created = await signUp(
12271231 ctx.app,
12281232 {
@@ -1233,8 +1237,7 @@ export function apiRoutes(router: Router<Handler>): void {
12331237 },
12341238 {
12351239 slot,
1236 // The token of an invite link (`core/invites.ts`), when signup is `invite`.
1237 invite: str(input.invite) === "" ? undefined : str(input.invite),
1240 invite,
12381241 address: clientAddress(ctx.request),
12391242 },
12401243 );
src/http/edge.ts +10
@@ -9,6 +9,7 @@
99 */
1010
1111 import { createHash, timingSafeEqual } from "node:crypto";
12 import type { User } from "../core/users.ts";
1213 import type { App } from "../app.ts";
1314 import { edgeSigninOn } from "../core/capacity.ts";
1415
@@ -33,6 +34,15 @@ export function edgeIdentity(
3334 return login === "" ? undefined : login;
3435 }
3536
37 /** The login the edge vouched for on this request when no account answered to it. */
38 export function edgeStrangerLogin(
39 request: Request,
40 edgeToken: string | undefined,
41 user: User | undefined,
42 ): string | undefined {
43 return user === undefined ? edgeIdentity(request, edgeToken) : undefined;
44 }
45
3646 // Compared as fixed-width digests, so neither the time taken nor the
3747 // lengths compared depend on the secret.
3848 function sameSecret(a: string, b: string): boolean {
src/http/server.ts +3 −19
@@ -64,7 +64,6 @@ import { trackPullHead } from "../core/pulls.ts";
6464 import { createRepo, repositoryLimitMessage, resolveOwner } from "../actions/repos.ts";
6565 import { sessionOf } from "../core/sessions.ts";
6666 import { scopeAllows, verifyToken } from "../core/tokens.ts";
67 import { forgeHost } from "../core/commit_identity.ts";
6867 import {
6968 ensureEdgeUser,
7069 getUserByName,
@@ -72,7 +71,6 @@ import {
7271 verifyPasswordThrottled,
7372 } from "../core/users.ts";
7473 import { activeEdgeToken, edgeIdentity } from "./edge.ts";
75 import { signupClosed } from "../actions/users.ts";
7674 import { liveResponse, readyResponse } from "./health.ts";
7775 import { running } from "../kernel/version.ts";
7876 import { runGit } from "../git/env.ts";
@@ -517,23 +515,9 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
517515 // in; its word outranks a cookie of ours.
518516 const login = edgeIdentity(request, activeEdgeToken(app));
519517 if (login !== undefined) {
520 const existed = getUserByName(app.db, login) !== undefined;
521 // Existing accounts sign in; a new one is made only while signup allows.
522 const user =
523 !existed && signupClosed(app)
524 ? undefined
525 : await ensureEdgeUser(app.db, login, forgeHost(app.config));
526 if (user) {
527 // An account the edge made is audited like one made at /signup.
528 if (!existed) {
529 app.bus.append({
530 kind: "user.created",
531 actorId: user.id,
532 payload: { via: "edge", address: clientAddress(request) },
533 });
534 }
535 return { user, scope: "admin", via: "edge" };
536 }
518 // Existing accounts sign in; an unknown login is signed out (/signup).
519 const user = ensureEdgeUser(app.db, login);
520 if (user) return { user, scope: "admin", via: "edge" };
537521 }
538522
539523 const session = cookieValue(request, "forge_session");
src/tui/screens/signin.ts +11 −4
@@ -8,6 +8,7 @@
88 * account while signup allows — not an admin; the operator makes the admin.
99 */
1010
11 import { activeEdgeToken } from "../../http/edge.ts";
1112 import { INVITE_SPENT_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
1213 import { SignupSlot, waitText } from "../../actions/signup-window.ts";
1314 import { hasAnyUser } from "../../core/users.ts";
@@ -42,8 +43,14 @@ const CREATE: Field[] = [
4243 const INVITE_BLANK_MESSAGE = "Paste the token from your invite link.";
4344 const INVITE: Field = { label: "Invite", secret: true, hint: "The token from your invite link." };
4445
45 /** Behind an edge the terminal cannot know the login the edge will vouch for. */
46 const EDGE_SIGNUP_MESSAGE = "Sign up on the page: accounts here are tied to the login the edge vouches for.";
46 /**
47 * Behind an edge the terminal cannot see the login the edge vouches for, so
48 * sign-up is on the web page only, where the name is pinned to that login.
49 */
50 const edgeSignupMessage = (f: Session): string =>

Low: this branch (no FORGE_URL) has no test. tui-signup-edge.test.ts only builds the app with a URL. Add a case without one so a relative or empty URL can't come back unnoticed.

51 f.app.config.url === undefined
outdated

Low: when FORGE_URL is unset, this line reads "Sign up on the web page only: /signup", a relative path the terminal user cannot open. Print the full URL only when config.url is set, and otherwise just say "on the web page".

52 ? "Sign up on the web page only."
53 : `Sign up on the web page only: ${f.app.config.url}/signup`;
4754
4855 export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
4956 // "Is this forge empty?" is a existence question, not a count: `COUNT(*)`
@@ -55,9 +62,9 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
5562 const invited = (): boolean => getSignup(f.db) === "invite";
5663 // Behind an edge no account is made here: it would be one the edge may later
5764 // sign a platform user of the same name into. Sign-in is unchanged.
58 const edge = f.app.config.edgeToken !== undefined;
65 const edge = activeEdgeToken(f.app) !== undefined;
5966 const closed = (): boolean => edge || (!invited() && signupClosed(f.app));
60 const closedText = (): string => (edge ? EDGE_SIGNUP_MESSAGE : SIGNUP_CLOSED_MESSAGE);
67 const closedText = (): string => (edge ? edgeSignupMessage(f) : SIGNUP_CLOSED_MESSAGE);
6168 let mode: "signin" | "create" = first && !closed() ? "create" : "signin";
6269 let values = ["", "", "", ""];
6370 let focus = 0;
src/web/chrome.ts +3 −1
@@ -13,7 +13,7 @@ import { mayDirectCrew } from "../crew/direct.ts";
1313 import { mayReadRuns } from "../actions/runs.ts";
1414 import { getAppByRepo } from "../core/apps.ts";
1515 import { audienceFacts, audienceOf } from "../core/audience.ts";
16 import { activeEdgeToken, edgeLoginUrl } from "../http/edge.ts";
16 import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../http/edge.ts";
1717 import {
1818 canAdmin,
1919 canRead,
@@ -94,6 +94,8 @@ export function signInHref(ctx: Ctx): string {
9494 const next = ctx.url.pathname + ctx.url.search;
9595 // Behind a platform edge the edge is how people sign in; an account it
9696 // made has no password here, so the password form would only refuse them.
97 if (edgeStrangerLogin(ctx.request, activeEdgeToken(ctx.app), ctx.auth.user) !== undefined)
outdated

Right for redirects and the topbar. The / hero (studio/home.ts:491) uses its own edgeToken === undefined ? … : edgeLoginUrl(back) branch and was missed, so the biggest button on the front page still loops.

98 return "/signup";
9799 if (activeEdgeToken(ctx.app) !== undefined) return edgeLoginUrl(next);
98100 return `/signin?next=${encodeURIComponent(next)}`;
99101 }
src/web/layout.ts +4 −3
@@ -13,7 +13,7 @@ import { appsForRepos } from "../core/apps.ts";
1313 import { listReposVisibleTo } from "../core/repos.ts";
1414 import type { User } from "../core/users.ts";
1515 import type { Ctx } from "../http/context.ts";
16 import { activeEdgeToken, edgeLoginUrl, edgeLogoutUrl } from "../http/edge.ts";
16 import { activeEdgeToken, edgeLoginUrl, edgeLogoutUrl, edgeStrangerLogin } from "../http/edge.ts";
1717 import { json } from "../http/respond.ts";
1818 import { sha256 } from "../kernel/hash.ts";
1919 import { secret } from "../kernel/id.ts";
@@ -477,8 +477,9 @@ function topbar(
477477 : { label: "Sign out", post: "/logout", icon: "signOut" },
478478 )}`
479479 : html`${guestMenu(theme)}${btn("Sign in", {
480 href:
481 activeEdgeToken(ctx.app) === undefined
480 href: edgeStrangerLogin(ctx.request, activeEdgeToken(ctx.app), ctx.auth.user) !== undefined
481 ? "/signup"
482 : activeEdgeToken(ctx.app) === undefined
482483 ? "/signin"
483484 : edgeLoginUrl(ctx.url.pathname + ctx.url.search),
484485 })}`}
src/web/pages/signup-passkey.ts +4 −1
@@ -11,12 +11,13 @@ import { plausibleEmail, signUpWithPasskey, signupClosed, HUMAN_CHECK_MESSAGE, S
1111 import { createSession } from "../../core/sessions.ts";
1212 import { getUserByName } from "../../core/users.ts";
1313 import { isSafeName } from "../../git/guard.ts";
14 import { edgeStrangerLogin } from "../../http/edge.ts";
1415 import { crossOrigin, type Ctx } from "../../http/context.ts";
1516 import { STRANGER_MAX } from "../../http/limits.ts";
1617 import { clientAddress } from "../../http/peer.ts";
1718 import { boundedBody, json, str } from "../../http/respond.ts";
1819 import { relying, store } from "./passkeys.ts";
19 import { inviteOf, safeNext, sessionCookie, signinAllowed, signupSlot } from "./site.ts";
20 import { EDGE_NAME_MESSAGE, inviteOf, pinnedTo, safeNext, sessionCookie, signinAllowed, signupSlot } from "./site.ts";
2021
2122 const refuse = (status: number, message: string) =>
2223 json({ error: "refused", message }, status);
@@ -28,6 +29,7 @@ const WORDS: Record<string, string> = {
2829 username_reserved: "That name is reserved.",
2930 email_taken: "That email already has an account.",
3031 invalid_email: "That does not look like an email address.",
32 edge_name: EDGE_NAME_MESSAGE,
3133 human_check: HUMAN_CHECK_MESSAGE,
3234 passkey_taken: "That passkey is already registered.",
3335 signup_closed: SIGNUP_CLOSED_MESSAGE,
@@ -36,6 +38,7 @@ const WORDS: Record<string, string> = {
3638 /** Whether the name and address are fit to sign up with; else the words. */
3739 function problem(ctx: Ctx, username: string, email: string): string | undefined {
3840 if (!isSafeName(username)) return WORDS.invalid_username;
41 if (!pinnedTo(ctx, username, inviteOf(ctx))) return WORDS.edge_name;
3942 if (!plausibleEmail(email)) return WORDS.invalid_email;
4043 if (getUserByName(ctx.app.db, username)) return WORDS.username_taken;
4144 return undefined;
src/web/pages/site.ts +54 −5
@@ -25,7 +25,7 @@ import { isThemeName } from "../../design/themes.ts";
2525 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
2626 import { studioDoor, studioHome } from "../studio/home.ts";
2727 import { homeTop } from "../studio/asks_page.ts";
28 import { activeEdgeToken, edgeLoginUrl } from "../../http/edge.ts";
28 import { activeEdgeToken, edgeIdentity, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts";
2929 import { sshCommand } from "../../ssh/command.ts";
3030 import { hostOf, pageHead, signInFirst } from "../chrome.ts";
3131 import { SignupSlot, signupWindow } from "../../actions/signup-window.ts";
@@ -247,8 +247,11 @@ function authForm(
247247 // Behind a platform edge, that is the door: the button first, and the
248248 // password form kept for the accounts that have one — an account the edge
249249 // made has none, and the note says so before anyone types.
250 // A newcomer the edge vouched for is already through it: no button to go
251 // round again, and the name is theirs alone.
252 const vouched = edgeStrangerLogin(ctx.request, activeEdgeToken(ctx.app), ctx.auth.user);
250253 const edge =
251 activeEdgeToken(ctx.app) === undefined
254 activeEdgeToken(ctx.app) === undefined || vouched !== undefined
252255 ? undefined
253256 : (ctx.app.config.edgeName ?? "the platform");
254257 const throughEdge = when(
@@ -263,7 +266,7 @@ function authForm(
263266 <p class="muted small">
outdated

This note is now on the page every edge newcomer is sent to, but the "Continue with …" button above it (edgeLoginUrl) still sends them back to the platform and round again. Drop it when edgeStranger holds. Pin the username field to the vouched login so nobody can sign up under another platform user's name, which ensureEdgeUser would later sign that user into.

264267 ${kind === "signin"
265268 ? `Accounts made through ${name} sign in there and have no password here. The form below is for an account that was given one.`
266 : `Signing in through ${name} makes your account on first sight; the form below makes one with a password instead.`}
269 : `Signing in through ${name} does not make an account here; the form below does.`}
267270 </p>
268271 </div>`,
269272 );
@@ -277,10 +280,13 @@ function authForm(
277280 ${formField({
278281 label: "Username",
279282 name: "username",
280 value: values.username ?? "",
283 value: (kind === "signup" ? vouched : undefined) ?? values.username ?? "",
281284 autocomplete: "username",
282285 required: true,
283 autofocus: true,
286 ...(vouched === undefined || kind !== "signup" ? { autofocus: true } : {}),
287 ...(kind === "signup" && vouched !== undefined
288 ? { readonly: true }
outdated

Low: this hint never shows, because the next spread (kind === "signup") overwrites hint with "Letters, digits, dots and dashes." Either put this object after that spread or drop the hint.

289 : {}),
284290 ...(kind === "signup"
285291 ? {
286292 hint: "Letters, digits, dots and dashes.",
@@ -297,6 +303,8 @@ function authForm(
297303 value: values.email ?? "",
298304 autocomplete: "email",
299305 required: true,
306 // A pinned name is not editable, so the keyboard goes to the email.
307 ...(vouched !== undefined ? { autofocus: true } : {}),
300308 ...(errors.email ? { error: errors.email } : {}),
301309 }),
302310 )}
@@ -434,6 +442,8 @@ function signupClosedPage(ctx: Ctx): Response {
434442
435443 export function signupPage(ctx: Ctx): Response {
436444 if (signupClosed(ctx.app, inviteOf(ctx))) return signupClosedPage(ctx);
445 const first = throughEdgeFirst(ctx);
446 if (first) return first;
437447 return page({
438448 ctx,
439449 title: "Sign up",
@@ -538,12 +548,51 @@ export function verifyPage(ctx: Ctx): Response {
538548 );
539549 }
540550
551 export const EDGE_NAME_MESSAGE = "Sign up under the name your platform sign-in gave you.";
552
outdated

High: with no vouched login this returns true, so behind an edge an unvouched visitor can sign up as any name. The edge later signs the real platform user into that account. Behind an edge, require a vouched login: ctx.app.config.edgeToken === undefined || (vouched !== undefined && vouched.toLowerCase() === username.toLowerCase()).

553 /**
554 * Whether `username` is free to sign up. `invite` is the one the caller hands
555 * to `signUp`, so closed-ness is decided from the same token on every door.
556 * Behind an edge the edge's login is
557 * the only name a newcomer may take, and one it has not vouched for takes
558 * none: a name made here first would sign the platform's user of that name
559 * into someone else's account.
560 */
outdated

Take the invite as a parameter (pinnedTo(ctx, username, invite?: string)) rather than reading inviteOf(ctx) here. The closed check must use the same invite the caller's signUp uses, or a door that carries the invite somewhere other than the query bypasses the pin.

561 export function pinnedTo(ctx: Ctx, username: string, invite: string | undefined): boolean {
562 const token = activeEdgeToken(ctx.app);
563 // A closed door refuses on its own, and says so in its own words.
564 if (token === undefined || signupClosed(ctx.app, invite)) return true;
565 const vouched = edgeIdentity(ctx.request, token);
566 return vouched !== undefined && vouched.toLowerCase() === username.toLowerCase();
567 }
568
569 /** Behind an edge, a visitor it has not vouched for goes through it first. */
570 function throughEdgeFirst(ctx: Ctx): Response | undefined {
571 const token = activeEdgeToken(ctx.app);
572 if (token === undefined || edgeIdentity(ctx.request, token) !== undefined) return undefined;
573 const invite = inviteOf(ctx);
574 if (signupClosed(ctx.app, invite)) return undefined;
575 const query = invite === undefined ? "" : `?invite=${encodeURIComponent(invite)}`;
576 return redirect(edgeLoginUrl(`/signup${query}`));
577 }
578
541579 export async function signupSubmit(ctx: Ctx): Promise<Response> {
542580 // Posted by people with no account yet: a stranger's form.
543581 const form = await readForm(ctx, undefined, STRANGER_MAX);
544582 if (form instanceof Response) return form;
545583 const username = field(form, "username");
546584 const email = field(form, "email");
585 const first = throughEdgeFirst(ctx);
586 if (first) return first;
587 if (!pinnedTo(ctx, username, inviteOf(ctx)))
588 return page(
589 {
590 ctx,
591 title: "Sign up",
592 body: authForm(ctx, "signup", { values: { email }, errors: { username: EDGE_NAME_MESSAGE } }),
593 },
594 422,
595 );
547596 const slot = signupSlot(ctx);
548597 if (typeof slot === "number") {
549598 const wait = slot;
src/web/studio/home.ts +2 −2
@@ -22,7 +22,7 @@ import { LOOK_SIZES } from "../../core/looks.ts";
2222 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
2323 import { type User } from "../../core/users.ts";
2424 import type { Ctx } from "../../http/context.ts";
25 import { activeEdgeToken, edgeLoginUrl } from "../../http/edge.ts";
25 import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts";
2626 import { sshCommand } from "../../ssh/command.ts";
2727 import { repoBase, signInFirst } from "../chrome.ts";
2828 import { field, readForm, type Form } from "../form.ts";
@@ -488,7 +488,7 @@ export function studioDoor(ctx: Ctx): Response {
488488 <div class="bar">
489489 ${ctx.auth.user !== undefined
490490 ? null
491 : activeEdgeToken(ctx.app) === undefined
491 : activeEdgeToken(ctx.app) === undefined || edgeStrangerLogin(ctx.request, activeEdgeToken(ctx.app), ctx.auth.user) !== undefined
492492 ? html`${btn("Create an account", {
493493 href: `/signup${nextQuery}`,
494494 variant: "primary",
src/web/ui.ts +2
@@ -1002,6 +1002,7 @@ export interface FieldOptions {
10021002 error?: string;
10031003 required?: boolean;
10041004 autofocus?: boolean;
1005 readonly?: boolean;
10051006 autocomplete?: string;
10061007 placeholder?: string;
10071008 pattern?: string;
@@ -1023,6 +1024,7 @@ export function field(o: FieldOptions): Html {
10231024 ${o.error ? raw('aria-invalid="true"') : null}
10241025 ${o.required ? raw("required") : null}
10251026 ${o.autofocus ? raw("autofocus") : null}
1027 ${o.readonly ? raw("readonly") : null}
10261028 ${o.placeholder ? html`placeholder="${o.placeholder}"` : null}`;
10271029 const control =
10281030 o.type === "textarea"
test/accounts.test.ts +19 −9
@@ -97,6 +97,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
9797 const outsider = async (name: string) => {
9898 const res = await api("/api/v1/users", {
9999 method: "POST",
100 headers: { "x-plat-user": name, "x-plat-edge-token": EDGE },
100101 body: JSON.stringify({
101102 username: name,
102103 email: `${name}@outsider.test`,
@@ -121,16 +122,24 @@ describe("accounts: the owner sees, suspends and removes", () => {
121122 expect(typeof made?.payload["address"]).toBe("string");
122123 });
123124
124 test("an account the edge makes is audited too", async () => {
125 const res = await fetch(`${base}/api/v1/user`, {
126 headers: { "x-plat-user": "edgy", "x-plat-edge-token": EDGE },
125 test("an unknown edge login makes no account and no audit record; an old edge account still signs in", async () => {
126 const as = (login: string) =>
127 fetch(`${base}/api/v1/user`, {
128 headers: { "x-plat-user": login, "x-plat-edge-token": EDGE },
129 });
130 const before = listEvents(app.db, { kind: "user.created" }).length;
131 expect((await as("edgy")).status).toBe(401);
132 expect(getUserByName(app.db, "edgy")).toBeUndefined();
133 expect(listEvents(app.db, { kind: "user.created" }).length).toBe(before);
134 // An account the edge made when it still did, audited as it was then.
135 const old = await insertAgent(app.db, {
136 origin: "edge",
137 username: "edgy",
138 email: "[email protected]",
127139 });
128 expect(res.status).toBe(200);
129 const user = getUserByName(app.db, "edgy")!;
130 const made = listEvents(app.db, { kind: "user.created" }).find(
131 (e) => e.actorId === user.id,
132 );
133 expect(made?.payload["via"]).toBe("edge");
140 if (!old.ok) throw new Error(old.error);
141 app.bus.append({ kind: "user.created", actorId: old.value.id, payload: { via: "edge", address: "127.0.0.1" } });
142 expect((await as("edgy")).status).toBe(200);
134143 });
135144
136145 test("the owner hears of a new account once", async () => {
@@ -474,6 +483,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
474483 // The name stays taken, so nobody can step into it.
475484 const again = await api("/api/v1/users", {
476485 method: "POST",
486 headers: { "x-plat-user": "oscar", "x-plat-edge-token": EDGE },
477487 body: JSON.stringify({
478488 username: "oscar",
479489 email: "[email protected]",
test/edge-auth.test.ts +89 −1
@@ -5,6 +5,9 @@ import { join } from "node:path";
55 import { createApp, type App } from "../src/app.ts";
66 import { edgeIdentity, edgeLoginUrl, edgeLogoutUrl } from "../src/http/edge.ts";
77 import { serve, type Servers } from "../src/http/server.ts";
8 import { createUser, getUserByName } from "../src/core/users.ts";
9 import { mintInvite } from "../src/core/invites.ts";
10 import { setSignup } from "../src/settings/signup.ts";
811 import { seedAdmin } from "./helpers/admin.ts";
912
1013 const TOKEN = "edge-secret-0123456789";
@@ -69,6 +72,9 @@ describe("a forge behind the platform edge", () => {
6972 base = servers.url;
7073 const olive = await seedAdmin(app, { username: "olive", email: "[email protected]" });
7174 if (!olive.ok) throw new Error(olive.error);
75 // An account the edge made when it still did.
76 const ada = await createUser(app.db, { origin: "edge", username: "ada", email: "[email protected]" });
77 if (!ada.ok) throw new Error(ada.error);
7278 });
7379
7480 afterAll(async () => {
@@ -83,7 +89,68 @@ describe("a forge behind the platform edge", () => {
8389 expect(page).not.toContain('href="/signin"');
8490 });
8591
86 test("the first person the edge vouches for is made on sight, not as admin", async () => {
92 test("an unknown edge login creates no user and is offered /signup", async () => {
93 const stranger = { "x-plat-user": "newcomer", "x-plat-edge-token": TOKEN };
94 expect((await fetch(`${base}/api/v1/user`, { headers: stranger })).status).toBe(401);
95 expect(getUserByName(app.db, "newcomer")).toBeUndefined();
96 const page = await fetch(`${base}/`, { headers: stranger }).then((r) => r.text());
97 expect(page).toContain('href="/signup"');
98 expect(page).not.toContain("Sign out");
99 const gated = await fetch(`${base}/new`, { headers: stranger, redirect: "manual" });
100 expect(gated.headers.get("location")).toBe("/signup");
101 expect(getUserByName(app.db, "newcomer")).toBeUndefined();
102 });
103
104 test("a vouched newcomer's pages drop the edge button and pin the name", async () => {
105 const stranger = { "x-plat-user": "newcomer", "x-plat-edge-token": TOKEN };
106 const home = await fetch(`${base}/`, { headers: stranger }).then((r) => r.text());
107 expect(home).toContain('href="/signup"');
108 expect(home).not.toContain("/_plat/login");
109 const signup = await fetch(`${base}/signup`, { headers: stranger }).then((r) => r.text());
110 expect(signup).not.toContain("/_plat/login");
111 expect(signup).toContain('value="newcomer"');
112 expect(signup).toContain("readonly");
113 });
114
115 test("a vouched newcomer cannot sign up under another name", async () => {
116 const stranger = {
117 "x-plat-user": "newcomer",
118 "x-plat-edge-token": TOKEN,
119 "content-type": "application/x-www-form-urlencoded",
120 origin: "https://forge.example",
121 };
122 const form = (username: string) =>
123 new URLSearchParams({ username, email: "[email protected]", password: "correct horse battery" }).toString();
124 const res = await fetch(`${base}/signup`, { method: "POST", headers: stranger, body: form("grace") });
125 expect(res.status).toBe(422);
126 expect(await res.text()).toContain("Sign up under the name");
127 expect(getUserByName(app.db, "grace")).toBeUndefined();
128 const key = await fetch(`${base}/signup/passkey/options`, {
129 method: "POST",
130 headers: { ...stranger, "content-type": "application/json" },
131 body: JSON.stringify({ username: "grace", email: "[email protected]" }),
132 });
133 expect(key.status).toBe(422);
134 });
135
136 test("a visitor the edge has not vouched for cannot sign up behind an edge", async () => {
137 const headers = { "content-type": "application/x-www-form-urlencoded", origin: "https://forge.example" };
138 const body = new URLSearchParams({ username: "alice", email: "[email protected]", password: "correct horse battery" }).toString();
139 const res = await fetch(`${base}/signup`, { method: "POST", headers, body, redirect: "manual" });
140 expect(res.status).toBe(303);
141 expect(res.headers.get("location")).toContain("/_plat/login");
142 expect(getUserByName(app.db, "alice")).toBeUndefined();
143 const key = await fetch(`${base}/signup/passkey/options`, {
144 method: "POST",
145 headers: { ...headers, "content-type": "application/json" },
146 body: JSON.stringify({ username: "alice", email: "[email protected]" }),
147 });
148 expect(key.status).toBe(422);
149 const get = await fetch(`${base}/signup`, { redirect: "manual" });
150 expect(get.headers.get("location")).toContain("/_plat/login");
151 });
152
153 test("an account the edge made earlier still signs in, not as admin", async () => {
87154 const me = await fetch(`${base}/api/v1/user`, { headers: asAda });
88155 expect(me.status).toBe(200);
89156 const body = (await me.json()) as { username: string; isAdmin: boolean };
@@ -158,4 +225,25 @@ describe("a forge behind the platform edge", () => {
158225 });
159226 expect(((await me.json()) as { username: string }).username).toBe("ada");
160227 });
228
229 test("with signup by invite, a body invite does not lift the name pin", async () => {
230 const olive = getUserByName(app.db, "olive")!;
231 setSignup(app.db, "invite", olive.id);
232 try {
233 const join = (headers: Record<string, string>) => {
234 const { token } = mintInvite(app.db, olive.id);
235 return fetch(`${base}/api/v1/users`, {
236 method: "POST",
237 headers: { ...headers, origin: "https://forge.example" },
238 body: JSON.stringify({ username: "dave", email: "[email protected]", password: "correct horse battery", invite: token }),
239 });
240 };
241 // A vouched carol may not take dave's name, nor may a visitor nobody vouched for.
242 expect((await join({ "x-plat-user": "carol", "x-plat-edge-token": TOKEN })).status).toBe(403);
243 expect((await join({})).status).toBe(403);
244 expect(getUserByName(app.db, "dave")).toBeUndefined();
245 } finally {
246 setSignup(app.db, "open", olive.id);
247 }
248 });
161249 });
test/no-signup-admin.test.ts +3 −2
@@ -88,9 +88,10 @@ test("the first account from the terminal's sign-up is not an admin", async () =
8888 expect(admins(app)).toBe(0);
8989 });
9090
91 test("the first account the platform edge makes is not an admin", async () => {
91 test("the platform edge makes no account, so no admin", async () => {
9292 const app = await fresh();
93 expect((await ensureEdgeUser(app.db, "edgy", "f.test"))?.isAdmin).toBe(false);
93 expect(ensureEdgeUser(app.db, "edgy")).toBeUndefined();
94 expect(getUserByName(app.db, "edgy")).toBeUndefined();
9495 expect(admins(app)).toBe(0);
9596 });
9697
test/reserved-names.test.ts +3 −3
@@ -154,11 +154,11 @@ describe("reserved names", () => {
154154 });
155155
156156 test("the edge cannot bring in a reserved name", async () => {
157 expect(await ensureEdgeUser(app.db, "sc0ut", "f.dev")).toBeUndefined();
158 expect(await ensureEdgeUser(app.db, "admin", "f.dev")).toBeUndefined();
157 expect(ensureEdgeUser(app.db, "sc0ut")).toBeUndefined();
158 expect(ensureEdgeUser(app.db, "admin")).toBeUndefined();
159159 expect(getUserByName(app.db, "sc0ut")).toBeUndefined();
160160 // An account that already exists still signs in by its own name.
161 expect((await ensureEdgeUser(app.db, "trevato", "f.dev"))?.username).toBe(
161 expect(ensureEdgeUser(app.db, "trevato")?.username).toBe(
162162 "trevato",
163163 );
164164 });
test/signin-edge.test.ts +4 −1
@@ -32,6 +32,9 @@ describe("signin.edge", () => {
3232 });
3333 if (!made.ok) throw new Error(made.error);
3434 olive = made.value;
35 // An account the edge made before, with no password: it still signs in.
36 const ada = await seedAdmin(app, { username: "ada", email: "[email protected]" });
37 if (!ada.ok) throw new Error(ada.error);
3538 });
3639
3740 afterAll(async () => {
@@ -43,7 +46,7 @@ describe("signin.edge", () => {
4346 const who = (headers: Record<string, string>) =>
4447 fetch(`${base}/api/v1/user`, { headers });
4548
46 test("on by default; the edge makes an account and signs it in", async () => {
49 test("on by default; the edge signs in an account that exists", async () => {
4750 expect(capacityValue(app.db, "signin.edge")).toBe("on");
4851 expect((await who(asAda)).status).toBe(200);
4952 const page = await fetch(`${base}/`).then((r) => r.text());
test/signup-closed.test.ts +13 −7
@@ -11,7 +11,7 @@ import { createApp, type App } from "../src/app.ts";
1111 import { setLimit } from "../src/settings/limits.ts";
1212 import { setSignup, type SignupMode } from "../src/settings/signup.ts";
1313 import { signUp, signUpWithPasskey } from "../src/actions/users.ts";
14 import { getUserByName } from "../src/core/users.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
1515 import { serve, type Servers } from "../src/http/server.ts";
1616 import { fixtureSlot } from "./helpers/slot.ts";
1717
@@ -29,6 +29,9 @@ describe("closed signup", () => {
2929 app = createApp({ dataDir, port: 0, url: "https://forge.example", edgeToken: TOKEN });
3030 servers = await serve(app);
3131 setLimit(app.db, "signup.per_source", 1000, "setup");
32 // Accounts the edge made when it still did; they keep signing in.
33 for (const name of ["founder", "edgy"])
34 await createUser(app.db, { origin: "edge", username: name, email: `${name}@forge.example` });
3235 base = servers.url;
3336 });
3437 afterAll(async () => {
@@ -41,16 +44,18 @@ describe("closed signup", () => {
4144 const post = (username: string) =>
4245 fetch(`${base}/api/v1/users`, {
4346 method: "POST",
44 headers: { "content-type": "application/json", origin: "https://forge.example" },
47 headers: { "content-type": "application/json", origin: "https://forge.example", ...edge(username) },
4548 body: JSON.stringify({ username, email: `${username}@example.com`, password: "a-good-passphrase" }),
4649 });
4750 const me = (login: string) => fetch(`${base}/api/v1/user`, { headers: edge(login) });
4851
49 test("a fresh forge's first account is refused while closed, like any other", async () => {
52 test("closed: an unknown edge login is signed out and told signup is closed", async () => {
5053 mode("closed");
51 expect((await me("founder")).status).toBe(401);
52 expect((await post("founder")).status).toBe(403);
53 expect(getUserByName(app.db, "founder")).toBeUndefined();
54 expect((await me("stranger")).status).toBe(401);
55 const page = await fetch(`${base}/signup`, { headers: edge("stranger") }).then((r) => r.text());
56 expect(page).toContain("Signup is closed on this forge.");
57 expect(page).not.toContain("Sign out");
58 expect(getUserByName(app.db, "stranger")).toBeUndefined();
5459 });
5560
5661 for (const closed of ["closed", "invite"] as const) {
@@ -82,9 +87,10 @@ describe("closed signup", () => {
8287
8388 test("open makes accounts; switching takes effect on the next request", async () => {
8489 mode("open");
85 expect(await (await fetch(`${base}/signup`)).text()).toContain('name="password"');
90 expect(await (await fetch(`${base}/signup`, { headers: edge("newcomer") })).text()).toContain('name="password"');
8691 expect((await post("newcomer")).status).toBe(201);
8792 expect((await me("edgy")).status).toBe(200);
93 expect((await me("later")).status).toBe(401);
8894 mode("closed");
8995 expect((await post("late")).status).toBe(403);
9096 expect((await me("later")).status).toBe(401);
test/signup-gate-parent.test.ts −1
@@ -121,7 +121,6 @@ test("every caller of createUser says where its account comes from", async () =>
121121 "src/actions/users.ts": ["signup", "passkey"],
122122 "src/chaos/world.ts": ["seed"],
123123 "src/core/federation.ts": ["parent"],
124 "src/core/users.ts": ["edge"],
125124 "src/web/pages/claim.ts": ["claim"],
126125 "src/web/pages/everyday.ts": ["everyday"],
127126 });
test/signup.test.ts +13 −3
@@ -168,7 +168,12 @@ describe("signup closed (#2166)", () => {
168168 const post = (body: unknown) =>
169169 fetch(`${servers.url}/api/v1/users`, {
170170 method: "POST",
171 headers: { "content-type": "application/json" },
171 headers: {
172 "content-type": "application/json",
173 // Behind an edge a newcomer signs up as the login it vouched for.
174 "x-plat-user": String((body as { username?: string }).username ?? ""),
175 "x-plat-edge-token": EDGE,
176 },
172177 body: JSON.stringify(body),
173178 });
174179 const edge = (user: string) =>
@@ -265,6 +270,8 @@ describe("signup closed (#2166)", () => {
265270
266271 test("existing accounts still sign in by the edge while closed", async () => {
267272 setSignup(app.db, "open", "boot");
273 const old = await createUser(app.db, { origin: "edge", username: "returning", email: "[email protected]" });
274 expect(old.ok).toBe(true);
268275 expect((await edge("returning")).status).toBe(200);
269276 setSignup(app.db, "closed", "boot");
270277 expect((await edge("returning")).status).toBe(200);
@@ -272,7 +279,9 @@ describe("signup closed (#2166)", () => {
272279
273280 test("open: page has the form and the API makes the account", async () => {
274281 setSignup(app.db, "open", "boot");
275 const text = await fetch(`${servers.url}/signup`).then((r) => r.text());
282 const text = await fetch(`${servers.url}/signup`, {
283 headers: { "x-plat-user": "pager", "x-plat-edge-token": EDGE },
284 }).then((r) => r.text());
276285 expect(text).toContain('name="password"');
277286 expect((await post(newcomer())).status).toBe(201);
278287 });
@@ -282,6 +291,7 @@ describe("signup closed (#2166)", () => {
282291 expect((await post(newcomer())).status).toBe(403);
283292 setSignup(app.db, "open", "boot");
284293 expect((await post(newcomer())).status).toBe(201);
285 expect((await edge("latecomer")).status).toBe(200);
294 expect((await edge("latecomer")).status).toBe(401);
295 expect(getUserByName(app.db, "latecomer")).toBeUndefined();
286296 });
287297 });
test/tui-signup-edge.test.ts +3 −2
@@ -24,7 +24,7 @@ let forge: Forge;
2424
2525 beforeAll(async () => {
2626 dir = await mkdtemp(join(tmpdir(), "forge-tui-edge-signup-"));
27 forge = createApp({ dataDir: dir, port: 0, edgeToken: "edge-secret", disk: () => ({ freeBytes: 95, totalBytes: 100 }) });
27 forge = createApp({ dataDir: dir, port: 0, url: "https://forge.example", edgeToken: "edge-secret", disk: () => ({ freeBytes: 95, totalBytes: 100 }) });
2828 });
2929 afterAll(async () => {
3030 forge.close();
@@ -45,7 +45,8 @@ test("with an edge token the terminal sign-up makes no row", async () => {
4545 key("t", true); // no create mode to switch to
4646 await settleApp(app);
4747 expect(app.screen.toText()).not.toContain("Email");
48 expect(app.screen.toText()).toContain("Sign up on the page");
48 expect(app.screen.toText()).toContain("Sign up on the web page only");
49 expect(app.screen.toText()).toContain("https://forge.example/signup");
4950 type("mallory");
5051 key("tab");
5152 type("a-long-passphrase");
test/verify-email.test.ts +5 −6
@@ -220,12 +220,11 @@ describe("verified email", () => {
220220 edgeToken: "edge-secret",
221221 mailer: async () => true,
222222 });
223 const { ensureEdgeUser } = await import("../src/core/users.ts");
224 const first = await ensureEdgeUser(edged.db, "founder", "forge.example");
225 const second = await ensureEdgeUser(edged.db, "newcomer", "forge.example");
226 // The first is no admin, so it is not proven by being first.
227 expect(emailVerified(edged.db, first!.id)).toBe(false);
228 expect(emailVerified(edged.db, second!.id)).toBe(false);
223 const { createUser, ensureEdgeUser } = await import("../src/core/users.ts");
224 // The edge makes none now; an account it made earlier is still unproven.
225 expect(ensureEdgeUser(edged.db, "founder")).toBeUndefined();
226 const made = await createUser(edged.db, { origin: "edge", username: "founder", email: "founder@forge.example" });
227 expect(made.ok && emailVerified(edged.db, made.value.id)).toBe(false);
229228 edged.close();
230229 });
231230

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs