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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2763-an-unknown-edge-sign-in-creates-no-accou → main 7 commits fork 11 threads · 11 unresolved
README.md +6 −1
| @@ -351,7 +351,12 @@ adds `FORGE_URL` (the canonical public base URL, needed for OAuth callbacks), | ||
| 351 | 351 | `FORGE_ORIGIN_ALLOW` (hosts an operator vouches for on a LAN). Behind a |
| 352 | 352 | platform edge that signs people in for every app it serves, `FORGE_EDGE_TOKEN` |
| 353 | 353 | 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 | |
| 355 | 360 | `signin.edge` to `off` (Settings → Capacity → "Sign in through the platform |
| 356 | 361 | edge", boot default `FORGE_SIGNIN_EDGE`, also `/api/v1/admin/capacity` and the |
| 357 | 362 | MCP `capacity` tool) makes the forge ignore `X-Plat-User` even when the secret |
src/core/users.ts +8 −24
| @@ -6,7 +6,6 @@ | ||
| 6 | 6 | import { markUnproven, markVerified } from "./verify.ts"; |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { isSafeName } from "../git/guard.ts"; |
| 9 | import { noreplyAddress } from "./commit_identity.ts"; | |
| 10 | 9 | import { reservedName } from "./reserved.ts"; |
| 11 | 10 | import { id } from "../kernel/id.ts"; |
| 12 | 11 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -452,31 +451,16 @@ export function getUserByName( | ||
| 452 | 451 | |
| 453 | 452 | /** |
| 454 | 453 | * 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. | |
| 459 | 458 | */ |
| 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 { | |
| 465 | 460 | 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; | |
| 480 | 464 | } |
| 481 | 465 | |
| 482 | 466 | /** Why a password was not accepted: wrong, or tried too often to check. */ |
src/http/api.ts +6 −3
| @@ -369,7 +369,7 @@ import { | ||
| 369 | 369 | undoFleetOp, |
| 370 | 370 | type FleetError, |
| 371 | 371 | } 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"; | |
| 373 | 373 | import { |
| 374 | 374 | FIRST_ADMIN_TOKEN_MAX_S, |
| 375 | 375 | FIRST_ADMIN_TOKEN_MIN_S, |
| @@ -1223,6 +1223,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1223 | 1223 | const input = await boundedBody(ctx.request, STRANGER_MAX); |
| 1224 | 1224 | if (input === "too_large") |
outdated
|
||
| 1225 | 1225 | 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: |
||
| 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); | |
| 1226 | 1230 | const created = await signUp( |
| 1227 | 1231 | ctx.app, |
| 1228 | 1232 | { |
| @@ -1233,8 +1237,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1233 | 1237 | }, |
| 1234 | 1238 | { |
| 1235 | 1239 | 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, | |
| 1238 | 1241 | address: clientAddress(ctx.request), |
| 1239 | 1242 | }, |
| 1240 | 1243 | ); |
src/http/edge.ts +10
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { createHash, timingSafeEqual } from "node:crypto"; |
| 12 | import type { User } from "../core/users.ts"; | |
| 12 | 13 | import type { App } from "../app.ts"; |
| 13 | 14 | import { edgeSigninOn } from "../core/capacity.ts"; |
| 14 | 15 | |
| @@ -33,6 +34,15 @@ export function edgeIdentity( | ||
| 33 | 34 | return login === "" ? undefined : login; |
| 34 | 35 | } |
| 35 | 36 | |
| 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 | ||
| 36 | 46 | // Compared as fixed-width digests, so neither the time taken nor the |
| 37 | 47 | // lengths compared depend on the secret. |
| 38 | 48 | function sameSecret(a: string, b: string): boolean { |
src/http/server.ts +3 −19
| @@ -64,7 +64,6 @@ import { trackPullHead } from "../core/pulls.ts"; | ||
| 64 | 64 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../actions/repos.ts"; |
| 65 | 65 | import { sessionOf } from "../core/sessions.ts"; |
| 66 | 66 | import { scopeAllows, verifyToken } from "../core/tokens.ts"; |
| 67 | import { forgeHost } from "../core/commit_identity.ts"; | |
| 68 | 67 | import { |
| 69 | 68 | ensureEdgeUser, |
| 70 | 69 | getUserByName, |
| @@ -72,7 +71,6 @@ import { | ||
| 72 | 71 | verifyPasswordThrottled, |
| 73 | 72 | } from "../core/users.ts"; |
| 74 | 73 | import { activeEdgeToken, edgeIdentity } from "./edge.ts"; |
| 75 | import { signupClosed } from "../actions/users.ts"; | |
| 76 | 74 | import { liveResponse, readyResponse } from "./health.ts"; |
| 77 | 75 | import { running } from "../kernel/version.ts"; |
| 78 | 76 | import { runGit } from "../git/env.ts"; |
| @@ -517,23 +515,9 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 517 | 515 | // in; its word outranks a cookie of ours. |
| 518 | 516 | const login = edgeIdentity(request, activeEdgeToken(app)); |
| 519 | 517 | 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" }; | |
| 537 | 521 | } |
| 538 | 522 | |
| 539 | 523 | const session = cookieValue(request, "forge_session"); |
src/tui/screens/signin.ts +11 −4
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * account while signup allows — not an admin; the operator makes the admin. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { activeEdgeToken } from "../../http/edge.ts"; | |
| 11 | 12 | import { INVITE_SPENT_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; |
| 12 | 13 | import { SignupSlot, waitText } from "../../actions/signup-window.ts"; |
| 13 | 14 | import { hasAnyUser } from "../../core/users.ts"; |
| @@ -42,8 +43,14 @@ const CREATE: Field[] = [ | ||
| 42 | 43 | const INVITE_BLANK_MESSAGE = "Paste the token from your invite link."; |
| 43 | 44 | const INVITE: Field = { label: "Invite", secret: true, hint: "The token from your invite link." }; |
| 44 | 45 | |
| 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 |
||
| 51 | f.app.config.url === undefined | |
outdated
Low: when |
||
| 52 | ? "Sign up on the web page only." | |
| 53 | : `Sign up on the web page only: ${f.app.config.url}/signup`; | |
| 47 | 54 | |
| 48 | 55 | export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { |
| 49 | 56 | // "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 { | ||
| 55 | 62 | const invited = (): boolean => getSignup(f.db) === "invite"; |
| 56 | 63 | // Behind an edge no account is made here: it would be one the edge may later |
| 57 | 64 | // 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; | |
| 59 | 66 | 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); | |
| 61 | 68 | let mode: "signin" | "create" = first && !closed() ? "create" : "signin"; |
| 62 | 69 | let values = ["", "", "", ""]; |
| 63 | 70 | let focus = 0; |
src/web/chrome.ts +3 −1
| @@ -13,7 +13,7 @@ import { mayDirectCrew } from "../crew/direct.ts"; | ||
| 13 | 13 | import { mayReadRuns } from "../actions/runs.ts"; |
| 14 | 14 | import { getAppByRepo } from "../core/apps.ts"; |
| 15 | 15 | import { audienceFacts, audienceOf } from "../core/audience.ts"; |
| 16 | import { activeEdgeToken, edgeLoginUrl } from "../http/edge.ts"; | |
| 16 | import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../http/edge.ts"; | |
| 17 | 17 | import { |
| 18 | 18 | canAdmin, |
| 19 | 19 | canRead, |
| @@ -94,6 +94,8 @@ export function signInHref(ctx: Ctx): string { | ||
| 94 | 94 | const next = ctx.url.pathname + ctx.url.search; |
| 95 | 95 | // Behind a platform edge the edge is how people sign in; an account it |
| 96 | 96 | // 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 |
||
| 98 | return "/signup"; | |
| 97 | 99 | if (activeEdgeToken(ctx.app) !== undefined) return edgeLoginUrl(next); |
| 98 | 100 | return `/signin?next=${encodeURIComponent(next)}`; |
| 99 | 101 | } |
src/web/layout.ts +4 −3
| @@ -13,7 +13,7 @@ import { appsForRepos } from "../core/apps.ts"; | ||
| 13 | 13 | import { listReposVisibleTo } from "../core/repos.ts"; |
| 14 | 14 | import type { User } from "../core/users.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import { json } from "../http/respond.ts"; |
| 18 | 18 | import { sha256 } from "../kernel/hash.ts"; |
| 19 | 19 | import { secret } from "../kernel/id.ts"; |
| @@ -477,8 +477,9 @@ function topbar( | ||
| 477 | 477 | : { label: "Sign out", post: "/logout", icon: "signOut" }, |
| 478 | 478 | )}` |
| 479 | 479 | : 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 | |
| 482 | 483 | ? "/signin" |
| 483 | 484 | : edgeLoginUrl(ctx.url.pathname + ctx.url.search), |
| 484 | 485 | })}`} |
src/web/pages/signup-passkey.ts +4 −1
| @@ -11,12 +11,13 @@ import { plausibleEmail, signUpWithPasskey, signupClosed, HUMAN_CHECK_MESSAGE, S | ||
| 11 | 11 | import { createSession } from "../../core/sessions.ts"; |
| 12 | 12 | import { getUserByName } from "../../core/users.ts"; |
| 13 | 13 | import { isSafeName } from "../../git/guard.ts"; |
| 14 | import { edgeStrangerLogin } from "../../http/edge.ts"; | |
| 14 | 15 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| 15 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 16 | 17 | import { clientAddress } from "../../http/peer.ts"; |
| 17 | 18 | import { boundedBody, json, str } from "../../http/respond.ts"; |
| 18 | 19 | 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"; | |
| 20 | 21 | |
| 21 | 22 | const refuse = (status: number, message: string) => |
| 22 | 23 | json({ error: "refused", message }, status); |
| @@ -28,6 +29,7 @@ const WORDS: Record<string, string> = { | ||
| 28 | 29 | username_reserved: "That name is reserved.", |
| 29 | 30 | email_taken: "That email already has an account.", |
| 30 | 31 | invalid_email: "That does not look like an email address.", |
| 32 | edge_name: EDGE_NAME_MESSAGE, | |
| 31 | 33 | human_check: HUMAN_CHECK_MESSAGE, |
| 32 | 34 | passkey_taken: "That passkey is already registered.", |
| 33 | 35 | signup_closed: SIGNUP_CLOSED_MESSAGE, |
| @@ -36,6 +38,7 @@ const WORDS: Record<string, string> = { | ||
| 36 | 38 | /** Whether the name and address are fit to sign up with; else the words. */ |
| 37 | 39 | function problem(ctx: Ctx, username: string, email: string): string | undefined { |
| 38 | 40 | if (!isSafeName(username)) return WORDS.invalid_username; |
| 41 | if (!pinnedTo(ctx, username, inviteOf(ctx))) return WORDS.edge_name; | |
| 39 | 42 | if (!plausibleEmail(email)) return WORDS.invalid_email; |
| 40 | 43 | if (getUserByName(ctx.app.db, username)) return WORDS.username_taken; |
| 41 | 44 | return undefined; |
src/web/pages/site.ts +54 −5
| @@ -25,7 +25,7 @@ import { isThemeName } from "../../design/themes.ts"; | ||
| 25 | 25 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; |
| 26 | 26 | import { studioDoor, studioHome } from "../studio/home.ts"; |
| 27 | 27 | 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"; | |
| 29 | 29 | import { sshCommand } from "../../ssh/command.ts"; |
| 30 | 30 | import { hostOf, pageHead, signInFirst } from "../chrome.ts"; |
| 31 | 31 | import { SignupSlot, signupWindow } from "../../actions/signup-window.ts"; |
| @@ -247,8 +247,11 @@ function authForm( | ||
| 247 | 247 | // Behind a platform edge, that is the door: the button first, and the |
| 248 | 248 | // password form kept for the accounts that have one — an account the edge |
| 249 | 249 | // 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); | |
| 250 | 253 | const edge = |
| 251 | activeEdgeToken(ctx.app) === undefined | |
| 254 | activeEdgeToken(ctx.app) === undefined || vouched !== undefined | |
| 252 | 255 | ? undefined |
| 253 | 256 | : (ctx.app.config.edgeName ?? "the platform"); |
| 254 | 257 | const throughEdge = when( |
| @@ -263,7 +266,7 @@ function authForm( | ||
| 263 | 266 | <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 ( |
||
| 264 | 267 | ${kind === "signin" |
| 265 | 268 | ? `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.`} | |
| 267 | 270 | </p> |
| 268 | 271 | </div>`, |
| 269 | 272 | ); |
| @@ -277,10 +280,13 @@ function authForm( | ||
| 277 | 280 | ${formField({ |
| 278 | 281 | label: "Username", |
| 279 | 282 | name: "username", |
| 280 | value: values.username ?? "", | |
| 283 | value: (kind === "signup" ? vouched : undefined) ?? values.username ?? "", | |
| 281 | 284 | autocomplete: "username", |
| 282 | 285 | 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 ( |
||
| 289 | : {}), | |
| 284 | 290 | ...(kind === "signup" |
| 285 | 291 | ? { |
| 286 | 292 | hint: "Letters, digits, dots and dashes.", |
| @@ -297,6 +303,8 @@ function authForm( | ||
| 297 | 303 | value: values.email ?? "", |
| 298 | 304 | autocomplete: "email", |
| 299 | 305 | required: true, |
| 306 | // A pinned name is not editable, so the keyboard goes to the email. | |
| 307 | ...(vouched !== undefined ? { autofocus: true } : {}), | |
| 300 | 308 | ...(errors.email ? { error: errors.email } : {}), |
| 301 | 309 | }), |
| 302 | 310 | )} |
| @@ -434,6 +442,8 @@ function signupClosedPage(ctx: Ctx): Response { | ||
| 434 | 442 | |
| 435 | 443 | export function signupPage(ctx: Ctx): Response { |
| 436 | 444 | if (signupClosed(ctx.app, inviteOf(ctx))) return signupClosedPage(ctx); |
| 445 | const first = throughEdgeFirst(ctx); | |
| 446 | if (first) return first; | |
| 437 | 447 | return page({ |
| 438 | 448 | ctx, |
| 439 | 449 | title: "Sign up", |
| @@ -538,12 +548,51 @@ export function verifyPage(ctx: Ctx): Response { | ||
| 538 | 548 | ); |
| 539 | 549 | } |
| 540 | 550 | |
| 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: |
||
| 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 ( |
||
| 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 | ||
| 541 | 579 | export async function signupSubmit(ctx: Ctx): Promise<Response> { |
| 542 | 580 | // Posted by people with no account yet: a stranger's form. |
| 543 | 581 | const form = await readForm(ctx, undefined, STRANGER_MAX); |
| 544 | 582 | if (form instanceof Response) return form; |
| 545 | 583 | const username = field(form, "username"); |
| 546 | 584 | 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 | ); | |
| 547 | 596 | const slot = signupSlot(ctx); |
| 548 | 597 | if (typeof slot === "number") { |
| 549 | 598 | const wait = slot; |
src/web/studio/home.ts +2 −2
| @@ -22,7 +22,7 @@ import { LOOK_SIZES } from "../../core/looks.ts"; | ||
| 22 | 22 | import { canRead, getRepo, type Repo } from "../../core/repos.ts"; |
| 23 | 23 | import { type User } from "../../core/users.ts"; |
| 24 | 24 | import type { Ctx } from "../../http/context.ts"; |
| 25 | import { activeEdgeToken, edgeLoginUrl } from "../../http/edge.ts"; | |
| 25 | import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts"; | |
| 26 | 26 | import { sshCommand } from "../../ssh/command.ts"; |
| 27 | 27 | import { repoBase, signInFirst } from "../chrome.ts"; |
| 28 | 28 | import { field, readForm, type Form } from "../form.ts"; |
| @@ -488,7 +488,7 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 488 | 488 | <div class="bar"> |
| 489 | 489 | ${ctx.auth.user !== undefined |
| 490 | 490 | ? null |
| 491 | : activeEdgeToken(ctx.app) === undefined | |
| 491 | : activeEdgeToken(ctx.app) === undefined || edgeStrangerLogin(ctx.request, activeEdgeToken(ctx.app), ctx.auth.user) !== undefined | |
| 492 | 492 | ? html`${btn("Create an account", { |
| 493 | 493 | href: `/signup${nextQuery}`, |
| 494 | 494 | variant: "primary", |
src/web/ui.ts +2
| @@ -1002,6 +1002,7 @@ export interface FieldOptions { | ||
| 1002 | 1002 | error?: string; |
| 1003 | 1003 | required?: boolean; |
| 1004 | 1004 | autofocus?: boolean; |
| 1005 | readonly?: boolean; | |
| 1005 | 1006 | autocomplete?: string; |
| 1006 | 1007 | placeholder?: string; |
| 1007 | 1008 | pattern?: string; |
| @@ -1023,6 +1024,7 @@ export function field(o: FieldOptions): Html { | ||
| 1023 | 1024 | ${o.error ? raw('aria-invalid="true"') : null} |
| 1024 | 1025 | ${o.required ? raw("required") : null} |
| 1025 | 1026 | ${o.autofocus ? raw("autofocus") : null} |
| 1027 | ${o.readonly ? raw("readonly") : null} | |
| 1026 | 1028 | ${o.placeholder ? html`placeholder="${o.placeholder}"` : null}`; |
| 1027 | 1029 | const control = |
| 1028 | 1030 | o.type === "textarea" |
test/accounts.test.ts +19 −9
| @@ -97,6 +97,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 97 | 97 | const outsider = async (name: string) => { |
| 98 | 98 | const res = await api("/api/v1/users", { |
| 99 | 99 | method: "POST", |
| 100 | headers: { "x-plat-user": name, "x-plat-edge-token": EDGE }, | |
| 100 | 101 | body: JSON.stringify({ |
| 101 | 102 | username: name, |
| 102 | 103 | email: `${name}@outsider.test`, |
| @@ -121,16 +122,24 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 121 | 122 | expect(typeof made?.payload["address"]).toBe("string"); |
| 122 | 123 | }); |
| 123 | 124 | |
| 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]", | |
| 127 | 139 | }); |
| 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); | |
| 134 | 143 | }); |
| 135 | 144 | |
| 136 | 145 | test("the owner hears of a new account once", async () => { |
| @@ -474,6 +483,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 474 | 483 | // The name stays taken, so nobody can step into it. |
| 475 | 484 | const again = await api("/api/v1/users", { |
| 476 | 485 | method: "POST", |
| 486 | headers: { "x-plat-user": "oscar", "x-plat-edge-token": EDGE }, | |
| 477 | 487 | body: JSON.stringify({ |
| 478 | 488 | username: "oscar", |
| 479 | 489 | email: "[email protected]", |
test/edge-auth.test.ts +89 −1
| @@ -5,6 +5,9 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { edgeIdentity, edgeLoginUrl, edgeLogoutUrl } from "../src/http/edge.ts"; |
| 7 | 7 | 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"; | |
| 8 | 11 | import { seedAdmin } from "./helpers/admin.ts"; |
| 9 | 12 | |
| 10 | 13 | const TOKEN = "edge-secret-0123456789"; |
| @@ -69,6 +72,9 @@ describe("a forge behind the platform edge", () => { | ||
| 69 | 72 | base = servers.url; |
| 70 | 73 | const olive = await seedAdmin(app, { username: "olive", email: "[email protected]" }); |
| 71 | 74 | 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); | |
| 72 | 78 | }); |
| 73 | 79 | |
| 74 | 80 | afterAll(async () => { |
| @@ -83,7 +89,68 @@ describe("a forge behind the platform edge", () => { | ||
| 83 | 89 | expect(page).not.toContain('href="/signin"'); |
| 84 | 90 | }); |
| 85 | 91 | |
| 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 () => { | |
| 87 | 154 | const me = await fetch(`${base}/api/v1/user`, { headers: asAda }); |
| 88 | 155 | expect(me.status).toBe(200); |
| 89 | 156 | const body = (await me.json()) as { username: string; isAdmin: boolean }; |
| @@ -158,4 +225,25 @@ describe("a forge behind the platform edge", () => { | ||
| 158 | 225 | }); |
| 159 | 226 | expect(((await me.json()) as { username: string }).username).toBe("ada"); |
| 160 | 227 | }); |
| 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 | }); | |
| 161 | 249 | }); |
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 () = | ||
| 88 | 88 | expect(admins(app)).toBe(0); |
| 89 | 89 | }); |
| 90 | 90 | |
| 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 () => { | |
| 92 | 92 | 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(); | |
| 94 | 95 | expect(admins(app)).toBe(0); |
| 95 | 96 | }); |
| 96 | 97 |
test/reserved-names.test.ts +3 −3
| @@ -154,11 +154,11 @@ describe("reserved names", () => { | ||
| 154 | 154 | }); |
| 155 | 155 | |
| 156 | 156 | 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(); | |
| 159 | 159 | expect(getUserByName(app.db, "sc0ut")).toBeUndefined(); |
| 160 | 160 | // 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( | |
| 162 | 162 | "trevato", |
| 163 | 163 | ); |
| 164 | 164 | }); |
test/signin-edge.test.ts +4 −1
| @@ -32,6 +32,9 @@ describe("signin.edge", () => { | ||
| 32 | 32 | }); |
| 33 | 33 | if (!made.ok) throw new Error(made.error); |
| 34 | 34 | 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); | |
| 35 | 38 | }); |
| 36 | 39 | |
| 37 | 40 | afterAll(async () => { |
| @@ -43,7 +46,7 @@ describe("signin.edge", () => { | ||
| 43 | 46 | const who = (headers: Record<string, string>) => |
| 44 | 47 | fetch(`${base}/api/v1/user`, { headers }); |
| 45 | 48 | |
| 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 () => { | |
| 47 | 50 | expect(capacityValue(app.db, "signin.edge")).toBe("on"); |
| 48 | 51 | expect((await who(asAda)).status).toBe(200); |
| 49 | 52 | 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"; | ||
| 11 | 11 | import { setLimit } from "../src/settings/limits.ts"; |
| 12 | 12 | import { setSignup, type SignupMode } from "../src/settings/signup.ts"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 16 | import { fixtureSlot } from "./helpers/slot.ts"; |
| 17 | 17 | |
| @@ -29,6 +29,9 @@ describe("closed signup", () => { | ||
| 29 | 29 | app = createApp({ dataDir, port: 0, url: "https://forge.example", edgeToken: TOKEN }); |
| 30 | 30 | servers = await serve(app); |
| 31 | 31 | 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` }); | |
| 32 | 35 | base = servers.url; |
| 33 | 36 | }); |
| 34 | 37 | afterAll(async () => { |
| @@ -41,16 +44,18 @@ describe("closed signup", () => { | ||
| 41 | 44 | const post = (username: string) => |
| 42 | 45 | fetch(`${base}/api/v1/users`, { |
| 43 | 46 | method: "POST", |
| 44 | headers: { "content-type": "application/json", origin: "https://forge.example" }, | |
| 47 | headers: { "content-type": "application/json", origin: "https://forge.example", ...edge(username) }, | |
| 45 | 48 | body: JSON.stringify({ username, email: `${username}@example.com`, password: "a-good-passphrase" }), |
| 46 | 49 | }); |
| 47 | 50 | const me = (login: string) => fetch(`${base}/api/v1/user`, { headers: edge(login) }); |
| 48 | 51 | |
| 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 () => { | |
| 50 | 53 | 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(); | |
| 54 | 59 | }); |
| 55 | 60 | |
| 56 | 61 | for (const closed of ["closed", "invite"] as const) { |
| @@ -82,9 +87,10 @@ describe("closed signup", () => { | ||
| 82 | 87 | |
| 83 | 88 | test("open makes accounts; switching takes effect on the next request", async () => { |
| 84 | 89 | 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"'); | |
| 86 | 91 | expect((await post("newcomer")).status).toBe(201); |
| 87 | 92 | expect((await me("edgy")).status).toBe(200); |
| 93 | expect((await me("later")).status).toBe(401); | |
| 88 | 94 | mode("closed"); |
| 89 | 95 | expect((await post("late")).status).toBe(403); |
| 90 | 96 | 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 () => | ||
| 121 | 121 | "src/actions/users.ts": ["signup", "passkey"], |
| 122 | 122 | "src/chaos/world.ts": ["seed"], |
| 123 | 123 | "src/core/federation.ts": ["parent"], |
| 124 | "src/core/users.ts": ["edge"], | |
| 125 | 124 | "src/web/pages/claim.ts": ["claim"], |
| 126 | 125 | "src/web/pages/everyday.ts": ["everyday"], |
| 127 | 126 | }); |
test/signup.test.ts +13 −3
| @@ -168,7 +168,12 @@ describe("signup closed (#2166)", () => { | ||
| 168 | 168 | const post = (body: unknown) => |
| 169 | 169 | fetch(`${servers.url}/api/v1/users`, { |
| 170 | 170 | 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 | }, | |
| 172 | 177 | body: JSON.stringify(body), |
| 173 | 178 | }); |
| 174 | 179 | const edge = (user: string) => |
| @@ -265,6 +270,8 @@ describe("signup closed (#2166)", () => { | ||
| 265 | 270 | |
| 266 | 271 | test("existing accounts still sign in by the edge while closed", async () => { |
| 267 | 272 | 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); | |
| 268 | 275 | expect((await edge("returning")).status).toBe(200); |
| 269 | 276 | setSignup(app.db, "closed", "boot"); |
| 270 | 277 | expect((await edge("returning")).status).toBe(200); |
| @@ -272,7 +279,9 @@ describe("signup closed (#2166)", () => { | ||
| 272 | 279 | |
| 273 | 280 | test("open: page has the form and the API makes the account", async () => { |
| 274 | 281 | 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()); | |
| 276 | 285 | expect(text).toContain('name="password"'); |
| 277 | 286 | expect((await post(newcomer())).status).toBe(201); |
| 278 | 287 | }); |
| @@ -282,6 +291,7 @@ describe("signup closed (#2166)", () => { | ||
| 282 | 291 | expect((await post(newcomer())).status).toBe(403); |
| 283 | 292 | setSignup(app.db, "open", "boot"); |
| 284 | 293 | 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(); | |
| 286 | 296 | }); |
| 287 | 297 | }); |
test/tui-signup-edge.test.ts +3 −2
| @@ -24,7 +24,7 @@ let forge: Forge; | ||
| 24 | 24 | |
| 25 | 25 | beforeAll(async () => { |
| 26 | 26 | 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 }) }); | |
| 28 | 28 | }); |
| 29 | 29 | afterAll(async () => { |
| 30 | 30 | forge.close(); |
| @@ -45,7 +45,8 @@ test("with an edge token the terminal sign-up makes no row", async () => { | ||
| 45 | 45 | key("t", true); // no create mode to switch to |
| 46 | 46 | await settleApp(app); |
| 47 | 47 | 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"); | |
| 49 | 50 | type("mallory"); |
| 50 | 51 | key("tab"); |
| 51 | 52 | type("a-long-passphrase"); |
test/verify-email.test.ts +5 −6
| @@ -220,12 +220,11 @@ describe("verified email", () => { | ||
| 220 | 220 | edgeToken: "edge-secret", |
| 221 | 221 | mailer: async () => true, |
| 222 | 222 | }); |
| 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); | |
| 229 | 228 | edged.close(); |
| 230 | 229 | }); |
| 231 | 230 |
Nit: the function never makes an account now, so the name
ensureis misleading. Something likeedgeUserorfindEdgeUserwould match what the doc comment says.