Public: anyone can read this; the crew can change it.
/login and /sign-in answer 308 to /signin, keeping a same-origin next #7728
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7721-login-and-sign-in-answer-308-to-signin-k → main 3 commits fork
5 files
changed +75
−1
docs/design/audit.md +3 −1
| @@ -33,7 +33,7 @@ their own vocabulary instead of composing the named one. | ||
| 33 | 33 | |
| 34 | 34 | ## Finding 2 — the pages, and what each is for |
| 35 | 35 | |
| 36 | There are 64 top-level pages, one entry each: every static `GET` route in `src/http/server.ts` | |
| 36 | There are 66 top-level pages, one entry each: every static `GET` route in `src/http/server.ts` | |
| 37 | 37 | that returns a page (`test/design-audit.test.ts` holds this list to the router, |
| 38 | 38 | both ways). Each entry reads `route` — module · what it is for · what |
| 39 | 39 | duplicates it · what breaks `docs/DESIGN.md`. "Breaks" counts colour literals |
| @@ -108,6 +108,8 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 108 | 108 | - `/signin` — `pages/site.ts` · sign in · `/signin/parent` · colours 3 (file). |
| 109 | 109 | - `/signin/parent` — `http/rp.ts` · sign in with the forge that sent this one · `/signin` · no page (redirect). |
| 110 | 110 | - `/signin/parent/callback` — `http/rp.ts` · the return leg of that sign-in · none · no page (redirect). |
| 111 | - `/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). | |
| 112 | - `/sign-in` — `http/server.ts` (`signinAlias`) · the same, hyphenated · it is `/signin` · none (308, no page of its own). | |
| 111 | 113 | - `/signup` — `pages/site.ts` · make an account · `/signup` passkey flow (`signup-passkey.ts`) · colours 3 (file). |
| 112 | 114 | - `/verify` — `pages/site.ts` · confirm an email address · none · colours 3 (file). |
| 113 | 115 | - `/recover` — `pages/passkeys.ts` · get back in without a password · `/settings/passkeys` · none seen. |
src/http/server.ts +10
| @@ -120,6 +120,7 @@ import { | ||
| 120 | 120 | logoutSubmit, |
| 121 | 121 | newRepoPage, |
| 122 | 122 | newRepoSubmit, |
| 123 | safeNext, | |
| 123 | 124 | signinPage, |
| 124 | 125 | signinSubmit, |
| 125 | 126 | signupPage, |
| @@ -745,6 +746,15 @@ export function buildRouter(): Router<Handler> { | ||
| 745 | 746 | router.add("POST", "/home/archived/:op/undo", homeArchiveUndo); |
| 746 | 747 | router.add("GET", "/signin", signinPage); |
| 747 | 748 | router.add("POST", "/signin", signinSubmit); |
| 749 | // The addresses people type to sign in (#7721): a permanent move to | |
| 750 | // `/signin`, carrying `next` only when `safeNext` calls it a path here. | |
| 751 | const signinAlias = (ctx: Ctx): Response => { | |
| 752 | const next = safeNext(ctx.url.searchParams.get("next")); | |
| 753 | const location = next === undefined ? "/signin" : `/signin?${new URLSearchParams({ next })}`; | |
| 754 | return new Response(null, { status: 308, headers: { location } }); | |
| 755 | }; | |
| 756 | router.add("GET", "/login", signinAlias); | |
| 757 | router.add("GET", "/sign-in", signinAlias); | |
| 748 | 758 | router.add("GET", "/signin/parent", parentStart); |
| 749 | 759 | router.add("GET", "/signin/parent/callback", parentCallback); |
| 750 | 760 | router.add("GET", "/signup", signupPage); |
test/forge-looks.test.ts +2
| @@ -40,6 +40,8 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 40 | 40 | "/asks": "redirects to /#needs-you", |
| 41 | 41 | "/big": "redirects to /board?wall", |
| 42 | 42 | "/flow": "redirects to /boards", |
| 43 | "/login": "redirects to /signin", | |
| 44 | "/sign-in": "redirects to /signin", | |
| 43 | 45 | "/mcp": "a protocol endpoint, answers 405 to a GET", |
| 44 | 46 | "/_measure": "the page-load beacon, a POST endpoint", |
| 45 | 47 | "/events": "a live event stream that never ends", |
test/server-timing-sweep.test.ts +2
| @@ -44,8 +44,10 @@ const REDIRECTS = new Set([ | ||
| 44 | 44 | "/alice/forge/tells/new", |
| 45 | 45 | "/big", |
| 46 | 46 | "/flow", |
| 47 | "/login", | |
| 47 | 48 | "/settings/origins/callback", |
| 48 | 49 | "/settings/resilience", |
| 50 | "/sign-in", | |
| 49 | 51 | ]); |
| 50 | 52 | |
| 51 | 53 | /** Every GET pattern the router source registers, one-line or wrapped. */ |
test/signin-alias.test.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * `/login` and `/sign-in` are the addresses people type: each answers 308 to | |
| 3 | * `/signin`, keeping `next` only when it is a path on this forge (#7721). | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | ||
| 12 | let dataDir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dataDir = await mkdtemp(join(tmpdir(), "forge-signin-alias-")); | |
| 18 | app = createApp({ dataDir, port: 0, url: "https://forge.example" }); | |
| 19 | servers = await serve(app); | |
| 20 | }); | |
| 21 | afterAll(async () => { | |
| 22 | await servers.stop(); | |
| 23 | app.close(); | |
| 24 | await rm(dataDir, { recursive: true, force: true }); | |
| 25 | }); | |
| 26 | ||
| 27 | const go = (path: string) => fetch(`${servers.url}${path}`, { redirect: "manual" }); | |
| 28 | ||
| 29 | for (const alias of ["/login", "/sign-in"]) { | |
| 30 | describe(alias, () => { | |
| 31 | test("answers 308 to /signin", async () => { | |
| 32 | const res = await go(alias); | |
| 33 | expect(res.status).toBe(308); | |
| 34 | expect(res.headers.get("location")).toBe("/signin"); | |
| 35 | }); | |
| 36 | ||
| 37 | test("keeps a same-origin next", async () => { | |
| 38 | const res = await go(`${alias}?next=/settings`); | |
| 39 | expect(res.status).toBe(308); | |
| 40 | expect(res.headers.get("location")).toBe("/signin?next=%2Fsettings"); | |
| 41 | const back = new URL(res.headers.get("location")!, servers.url); | |
| 42 | expect(back.searchParams.get("next")).toBe("/settings"); | |
| 43 | }); | |
| 44 | ||
| 45 | test("drops a next that leaves the forge", async () => { | |
| 46 | for (const next of ["//evil.test", "https://evil.test", "/\\evil.test"]) { | |
| 47 | const res = await go(`${alias}?next=${encodeURIComponent(next)}`); | |
| 48 | expect(res.status).toBe(308); | |
| 49 | expect(res.headers.get("location")).toBe("/signin"); | |
| 50 | } | |
| 51 | }); | |
| 52 | }); | |
| 53 | } | |
| 54 | ||
| 55 | test("/signup is not an alias", async () => { | |
| 56 | const res = await go("/signup"); | |
| 57 | expect(res.status).not.toBe(308); | |
| 58 | }); |
new above
offline