From 38936858f2fe6b83504ccf5aa605d549a06daab9 Mon Sep 17 00:00:00 2001 From: muthanii Date: Sat, 1 Aug 2026 17:35:14 +0300 Subject: [PATCH 1/7] =?UTF-8?q?fix(qa):=20ISSUE-004=20=E2=80=94=20the=20ne?= =?UTF-8?q?w=20rate=20limiter=20made=20`pnpm=20test:e2e`=20unrunnable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clientIpFromRequest falls back to the literal "unknown" when no proxy sets x-forwarded-for, so every local caller shares one bucket. The e2e suite self-seeds a board per spec across 4 workers, blew the 10/min boards.create budget, and failed 8 of 18 specs on `POST /api/boards failed: 429` — taking out the release gate CLAUDE.md §9 and §10 depend on. enforceRateLimit now reads an optional RATE_LIMIT_ ceiling. Absent, non-numeric, or non-positive values keep the route's own default, so a typo cannot silently disable a limit, and production behaviour is unchanged. Co-Authored-By: Claude Opus 5 --- .env.example | 11 +++++++++ apps/web/lib/server/rate-limit.ts | 40 +++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index c8d03d5..168a371 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,14 @@ WS_INTERNAL_URL= # --- App --- NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# --- Rate limiting --- +# Per-bucket ceilings per 60s, as RATE_LIMIT_. Leave unset in +# production: the shipped defaults (boards.create = 10/min) assume a proxy that +# sets x-forwarded-for, so every abuser gets their own bucket. +# +# Locally there is no such header, so ALL callers share one bucket — and +# `pnpm test:e2e` self-seeds a board per spec across 4 workers, which blows a +# 10/min budget and fails with 429. This raises the local ceiling so the suite +# can run; the limiter still applies, just far above one developer's pace. +RATE_LIMIT_BOARDS_CREATE=200 diff --git a/apps/web/lib/server/rate-limit.ts b/apps/web/lib/server/rate-limit.ts index 3090852..5e0e8eb 100644 --- a/apps/web/lib/server/rate-limit.ts +++ b/apps/web/lib/server/rate-limit.ts @@ -147,16 +147,52 @@ export interface EnforceRateLimitOptions { store?: RateLimitStore; } +/** `boards.create` → `RATE_LIMIT_BOARDS_CREATE`. */ +function envNameForBucket(bucket: string): string { + return `RATE_LIMIT_${bucket.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}`; +} + +/** + * Per-bucket ceiling, overridable per environment. + * + * The shipped defaults are tuned for a public deployment behind a proxy that + * sets x-forwarded-for, where each abuser gets their own bucket. Without that + * header every caller shares the `ip:unknown` bucket — which is exactly the + * local case, and why the e2e suite (18 self-seeding specs, 4 workers, one + * apparent client) exhausted a 10/min budget and failed with 429. + * + * Read off process.env for the same reason as REDIS_URL above: lib/env's eager + * boot parse would make this module untestable. A missing, non-numeric, or + * non-positive value keeps the caller's default, so a typo can never silently + * disable a limit. + */ +function resolveLimit(bucket: string, fallback: number): number { + const raw = process.env[envNameForBucket(bucket)]; + if (raw === undefined || raw.trim() === "") return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + log.warn("rate_limit.bad_override", { bucket, value: raw, using: fallback }); + return fallback; + } + return parsed; +} + /** null when the request may proceed, otherwise the 429 to return as-is. */ export async function enforceRateLimit( request: Request, { bucket, limit, windowSec, scope, store }: EnforceRateLimitOptions, ): Promise { const subject = scope ?? `ip:${clientIpFromRequest(request)}`; - const result = await checkRateLimit({ key: `rl:${bucket}:${subject}`, limit, windowSec, store }); + const effectiveLimit = resolveLimit(bucket, limit); + const result = await checkRateLimit({ + key: `rl:${bucket}:${subject}`, + limit: effectiveLimit, + windowSec, + store, + }); if (result.allowed) return null; - log.warn("rate_limit.blocked", { bucket, subject, limit, windowSec }); + log.warn("rate_limit.blocked", { bucket, subject, limit: effectiveLimit, windowSec }); const response = jsonError( 429, "rate_limited", From a8c3997041c17764d1eda05fdab112e2a4823997 Mon Sep 17 00:00:00 2001 From: muthanii Date: Sat, 1 Aug 2026 17:35:14 +0300 Subject: [PATCH 2/7] =?UTF-8?q?test(qa):=20regression=20test=20for=20ISSUE?= =?UTF-8?q?-004=20=E2=80=94=20per-bucket=20rate=20limit=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../server/rate-limit.regression-1.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 apps/web/lib/server/rate-limit.regression-1.test.ts diff --git a/apps/web/lib/server/rate-limit.regression-1.test.ts b/apps/web/lib/server/rate-limit.regression-1.test.ts new file mode 100644 index 0000000..490862d --- /dev/null +++ b/apps/web/lib/server/rate-limit.regression-1.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { enforceRateLimit, type RateLimitStore } from "./rate-limit"; + +/** + * Regression: ISSUE-004 — the shipped 10/min boards.create ceiling applies to + * one shared `ip:unknown` bucket when no proxy header is present, so the + * self-seeding e2e suite failed 8/18 specs with 429. + * Found by /qa on 2026-08-01 + * Report: .gstack/qa-reports/qa-report-quorum-2026-08-01.md + */ + +function fakeStore(): RateLimitStore { + const entries = new Map(); + return { + async hit(key, windowSec) { + const count = (entries.get(key) ?? 0) + 1; + entries.set(key, count); + return { count, ttlMs: windowSec * 1_000 }; + }, + }; +} + +function req(): Request { + return new Request("http://localhost:3000/api/boards", { method: "POST" }); +} + +const ENV_KEY = "RATE_LIMIT_BOARDS_CREATE"; +const original = process.env[ENV_KEY]; + +afterEach(() => { + if (original === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = original; +}); + +/** Requests allowed before the first 429, capped so a broken override can't hang. */ +async function allowedBefore429(store: RateLimitStore, ceiling = 40): Promise { + for (let sent = 0; sent < ceiling; sent += 1) { + if (await enforceRateLimit(req(), { bucket: "boards.create", limit: 10, windowSec: 60, store })) + return sent; + } + return ceiling; +} + +describe("per-bucket limit override", () => { + it("raises the ceiling so a self-seeding suite is not blocked at 10", async () => { + process.env[ENV_KEY] = "25"; + expect(await allowedBefore429(fakeStore())).toBe(25); + }); + + it("keeps the route default when unset — production is unchanged", async () => { + delete process.env[ENV_KEY]; + expect(await allowedBefore429(fakeStore())).toBe(10); + }); + + it.each(["0", "-5", "abc", "10.5", " "])( + "ignores the unusable override %j rather than disabling the limit", + async (value) => { + process.env[ENV_KEY] = value; + expect(await allowedBefore429(fakeStore())).toBe(10); + }, + ); + + it("scopes the override to its own bucket", async () => { + process.env[ENV_KEY] = "25"; + const store = fakeStore(); + // A different bucket must not inherit boards.create's raised ceiling. + let allowed = 0; + while ( + allowed < 40 && + !(await enforceRateLimit(req(), { + bucket: "agents.connect", + limit: 10, + windowSec: 60, + store, + })) + ) { + allowed += 1; + } + expect(allowed).toBe(10); + }); +}); From 481db0cde36091ccdc290b77daf1719b4c66fa5e Mon Sep 17 00:00:00 2001 From: muthanii Date: Sat, 1 Aug 2026 17:51:15 +0300 Subject: [PATCH 3/7] =?UTF-8?q?fix(qa):=20ISSUE-005=20=E2=80=94=20a=20degr?= =?UTF-8?q?aded=20agent=20swallowed=20@mentions=20with=20no=20word=20in=20?= =?UTF-8?q?chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent degrades, dispatch skips its turns behind an info log and the board chat says nothing. Observed live: the agent went degraded after its endpoint failed, then 11 more @mentions were dropped in silence while the sender kept typing at it. The roster pill is real but small and collapsed; the chat is where the conversation is, and §7 forbids the silent refusal. Announces the transition INTO degraded, so one outage costs one message rather than one per dropped mention — the same restraint the rate-limit notice in turns.ts already applies. Co-Authored-By: Claude Opus 5 --- apps/ws/src/internal-api.ts | 42 ++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/ws/src/internal-api.ts b/apps/ws/src/internal-api.ts index f89daaa..e0ecd9d 100644 --- a/apps/ws/src/internal-api.ts +++ b/apps/ws/src/internal-api.ts @@ -24,7 +24,12 @@ import { agentStatusSchema } from "@quorum/shared/schemas/agent"; import type { ConsensusPolicy } from "@quorum/shared/schemas/policy"; import type { Proposal, ProposalInput } from "@quorum/shared/schemas/proposal"; import { agentOperationSchema, agentMessageSchema } from "@quorum/shared/schemas/turn"; -import { appendMessage, createProposalInDoc, setAgentStatus } from "@quorum/shared/yjs/doc"; +import { + appendMessage, + createProposalInDoc, + readAgentStatuses, + setAgentStatus, +} from "@quorum/shared/yjs/doc"; import { messageSchema } from "@quorum/shared/schemas/message"; import { serializeError, type Logger } from "./log"; @@ -46,6 +51,28 @@ export const agentStatusBodySchema = z.object({ status: agentStatusSchema, }); +type AgentStatus = z.infer; + +/** + * The board-chat notice for an agent status change, or null when the change + * needs none. + * + * A degraded agent stops answering @mentions and dispatch drops those turns + * with nothing but a server log, so someone keeps typing at an agent that went + * quiet. The roster pill alone is not enough: the chat is where they are + * talking, and §7 forbids the silent refusal. Only the transition INTO + * degraded speaks, so one outage costs one message rather than one per dropped + * mention (the same restraint as the rate-limit notice in turns.ts). + */ +export function degradedNotice( + previous: AgentStatus | undefined, + next: AgentStatus, + agentName: string, +): string | null { + if (next !== "degraded" || previous === "degraded") return null; + return `${agentName} stopped responding and is marked degraded — @mentions will not reach it until it recovers.`; +} + /** Load a board doc (via a Hocuspocus direct connection) and mutate it. */ export interface DocGateway { withDoc(boardId: string, fn: (doc: Y.Doc) => T): Promise; @@ -332,7 +359,20 @@ export function createInternalApi(deps: InternalApiDeps): InternalApi { if (agent === null) throw new HttpError(404, "agent is not on this board"); await deps.boards.setAgentStatus(body.agentId, body.status); await deps.docs.withDoc(body.boardId, (doc) => { + const previous = readAgentStatuses(doc)[body.agentId]?.status; setAgentStatus(doc, body.agentId, body.status, now()); + const notice = degradedNotice(previous, body.status, agent.name); + if (notice !== null) { + appendMessage(doc, { + id: newId("message"), + role: "system", + authorId: "system", + name: "Quorum", + content: notice, + createdAt: now(), + mentions: [], + }); + } }); deps.log.info("agent status updated", { boardId: body.boardId, From b75659ca10b713a7110560e3c93b46a31f27dbe3 Mon Sep 17 00:00:00 2001 From: muthanii Date: Sat, 1 Aug 2026 17:51:16 +0300 Subject: [PATCH 4/7] =?UTF-8?q?test(qa):=20regression=20test=20for=20ISSUE?= =?UTF-8?q?-005=20=E2=80=94=20degraded-agent=20chat=20notice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../ws/test/internal-api.regression-1.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 apps/ws/test/internal-api.regression-1.test.ts diff --git a/apps/ws/test/internal-api.regression-1.test.ts b/apps/ws/test/internal-api.regression-1.test.ts new file mode 100644 index 0000000..0d4e880 --- /dev/null +++ b/apps/ws/test/internal-api.regression-1.test.ts @@ -0,0 +1,36 @@ +/** + * Regression: ISSUE-005 — an agent that degraded kept silently swallowing + * @mentions. Dispatch skipped 11 turns with only a server log while the board + * chat showed nothing, so the sender had no idea the agent had stopped. + * Found by /qa on 2026-08-01 + * Report: .gstack/qa-reports/qa-report-quorum-2026-08-01.md + */ +import { describe, expect, it } from "vitest"; + +import { degradedNotice } from "../src/internal-api"; + +describe("degradedNotice", () => { + it("announces the agent by name when it first degrades", () => { + const notice = degradedNotice("ready", "degraded", "Researcher"); + expect(notice).toContain("Researcher"); + expect(notice).toContain("degraded"); + }); + + it("announces a degrade seen for the first time (no prior status in the doc)", () => { + expect(degradedNotice(undefined, "degraded", "Researcher")).not.toBeNull(); + }); + + it("stays silent while the agent is already degraded, so one outage is one message", () => { + expect(degradedNotice("degraded", "degraded", "Researcher")).toBeNull(); + }); + + it("speaks again only after the agent recovers and degrades a second time", () => { + expect(degradedNotice("degraded", "ready", "Researcher")).toBeNull(); + expect(degradedNotice("ready", "degraded", "Researcher")).not.toBeNull(); + }); + + it.each(["ready", "running"] as const)("says nothing about a healthy %s agent", (status) => { + expect(degradedNotice("degraded", status, "Researcher")).toBeNull(); + expect(degradedNotice("ready", status, "Researcher")).toBeNull(); + }); +}); From ffeb09c4835b24296553fed1fcc0c3f2637e86b4 Mon Sep 17 00:00:00 2001 From: muthanii Date: Sun, 2 Aug 2026 03:52:13 +0300 Subject: [PATCH 5/7] =?UTF-8?q?fix(qa):=20ISSUE-006=20=E2=80=94=20/api/aut?= =?UTF-8?q?h/signin=20was=20a=20blank=20page=20with=20no=20provider=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildProviders() returns [] when no OAuth keys are configured, which is the documented default ("providers are optional"). Auth.js then renders its built-in sign-in page with an empty provider list: a bare white card, in the product's light theme, with no text and no way back. The landing page linked straight to it, so one of the only two CTAs a first-time visitor sees led nowhere. Confirmed it is specifically the zero-provider case — configuring one provider makes the built-in page render normally. Replaces it with /signin (pages.signIn). With providers it lists them and hands off to the real OAuth flow; with none it says so and offers the guest path, which already works and is what the zero-setup principle points at. The landing affordance is now conditional on a provider existing, so nothing offers a sign-in that cannot happen. callbackUrl arrives from the query string, so safeCallbackUrl keeps the redirect same-origin. A leading "/" is not sufficient on its own: browsers read "//evil.example" and "/\evil.example" as protocol-relative, so those are rejected too. Co-Authored-By: Claude Opus 5 --- apps/web/app/(marketing)/page.tsx | 44 ++++++++------ apps/web/app/(marketing)/signin/page.tsx | 77 ++++++++++++++++++++++++ apps/web/auth.ts | 25 ++++++++ apps/web/lib/auth/safe-callback.ts | 19 ++++++ 4 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 apps/web/app/(marketing)/signin/page.tsx create mode 100644 apps/web/lib/auth/safe-callback.ts diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx index cfe4a23..d179a7b 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -1,11 +1,15 @@ import Link from "next/link"; +import { configuredProviders } from "@/auth"; import { NewBoardButton } from "@/components/marketing/NewBoardButton"; import { resolveViewer } from "@/lib/auth/viewer"; /** Minimal, calm landing: the one-liner, one button, a sign-in affordance. */ export default async function LandingPage() { const viewer = await resolveViewer(); + // No provider means nothing can sign you in, so offering it would send + // people to a page that can only apologise. + const canSignIn = configuredProviders().length > 0; return (
@@ -22,25 +26,27 @@ export default async function LandingPage() { -

- {viewer ? ( - <> - You're in as {viewer.name} - {viewer.kind === "guest" ? ( - <> - {" · "} - - Sign in to keep your boards - - - ) : null} - - ) : ( - - Sign in - - )} -

+ {viewer || canSignIn ? ( +

+ {viewer ? ( + <> + You're in as {viewer.name} + {viewer.kind === "guest" && canSignIn ? ( + <> + {" · "} + + Sign in to keep your boards + + + ) : null} + + ) : ( + + Sign in + + )} +

+ ) : null}
); } diff --git a/apps/web/app/(marketing)/signin/page.tsx b/apps/web/app/(marketing)/signin/page.tsx new file mode 100644 index 0000000..fa785f5 --- /dev/null +++ b/apps/web/app/(marketing)/signin/page.tsx @@ -0,0 +1,77 @@ +import Link from "next/link"; + +import { configuredProviders, signIn } from "@/auth"; +import { NewBoardButton } from "@/components/marketing/NewBoardButton"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { safeCallbackUrl } from "@/lib/auth/safe-callback"; + +interface SignInPageProps { + searchParams: Promise<{ callbackUrl?: string; error?: string }>; +} + +/** + * /signin — Auth.js `pages.signIn`, replacing the built-in page. + * + * Two states, because a bare setup ships with no OAuth keys (zero-setup + * principle) and the built-in page renders an empty card in that case: + * providers configured → a button each; none → say so plainly and offer the + * guest path, which already works and is what most people want anyway. + */ +export default async function SignInPage({ searchParams }: SignInPageProps) { + const { callbackUrl, error } = await searchParams; + const providers = configuredProviders(); + const redirectTo = safeCallbackUrl(callbackUrl); + + return ( +
+ + + + {providers.length > 0 ? "Sign in to Quorum" : "Sign-in isn’t set up"} + + + {providers.length > 0 + ? "Signing in keeps your boards across devices. You can also just start a board as a guest." + : "This deployment has no sign-in provider configured, so there’s nothing to sign in with yet. Guest boards work fine without one."} + + + + {error ? ( +

+ That sign-in attempt didn’t go through. Try again, or start a board as a guest. +

+ ) : null} + + {providers.map((provider) => ( +
{ + "use server"; + await signIn(provider.id, { redirectTo }); + }} + > + +
+ ))} + + {providers.length > 0 ? ( + + Back to Quorum + + ) : ( + <> + + + Back to Quorum + + + )} +
+
+
+ ); +} diff --git a/apps/web/auth.ts b/apps/web/auth.ts index da3652c..58bf1ff 100644 --- a/apps/web/auth.ts +++ b/apps/web/auth.ts @@ -36,6 +36,27 @@ function buildProviders(): NextAuthConfig["providers"] { return providers; } +/** What /signin renders a button for. Empty in a bare setup — see below. */ +export interface ConfiguredProvider { + id: string; + name: string; +} + +/** + * The providers actually wired up, derived from the SAME list Auth.js gets so + * the two can never disagree. + * + * Callers use this to decide whether signing in is possible at all: with no + * OAuth keys the list is empty, and anything that offers a sign-in affordance + * would otherwise send people to a page that cannot sign them in. + */ +export function configuredProviders(): ConfiguredProvider[] { + return buildProviders().map((provider) => { + const config = typeof provider === "function" ? provider() : provider; + return { id: config.id, name: config.name }; + }); +} + export const { handlers, auth, signIn, signOut } = NextAuth(() => ({ // Lazy config: getDb() is called per request, not at import, so importing // this module never opens a database connection by itself. @@ -50,6 +71,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth(() => ({ session: { strategy: "database" }, secret: env.AUTH_SECRET, trustHost: true, + // Auth.js's built-in page renders an empty card when no provider is + // configured, which is the documented default here — a dead end in the + // product's own light theme. Ours explains itself and always offers a way on. + pages: { signIn: "/signin", error: "/signin" }, providers: buildProviders(), callbacks: { session({ session, user }) { diff --git a/apps/web/lib/auth/safe-callback.ts b/apps/web/lib/auth/safe-callback.ts new file mode 100644 index 0000000..ca4cf64 --- /dev/null +++ b/apps/web/lib/auth/safe-callback.ts @@ -0,0 +1,19 @@ +/** + * Sanitiser for the `callbackUrl` Auth.js appends when it bounces someone to + * the sign-in page. The value arrives in the query string, so it is entirely + * attacker-controlled: a link like + * `/signin?callbackUrl=https://evil.example` would otherwise let a phisher use + * our own domain to redirect people after a real, successful sign-in. + * + * Only same-origin paths survive. A leading "/" is NOT enough on its own — + * browsers read "//evil.example" as protocol-relative and "/\evil.example" the + * same way, both absolute despite the leading slash. + */ +export function safeCallbackUrl(raw: string | undefined | null): string { + if (typeof raw !== "string" || raw === "") return "/"; + if (!raw.startsWith("/")) return "/"; + // Reject every scheme-ish second character; "/" and "\" are the two a + // browser treats as the start of an authority. + if (raw.startsWith("//") || raw.startsWith("/\\")) return "/"; + return raw; +} From 6248516af72e28f71b97b0a70072e4e2c293e803 Mon Sep 17 00:00:00 2001 From: muthanii Date: Sun, 2 Aug 2026 03:52:13 +0300 Subject: [PATCH 6/7] =?UTF-8?q?test(qa):=20regression=20test=20for=20ISSUE?= =?UTF-8?q?-006=20=E2=80=94=20signin=20callbackUrl=20open=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- apps/web/lib/auth/safe-callback.test.ts | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/web/lib/auth/safe-callback.test.ts diff --git a/apps/web/lib/auth/safe-callback.test.ts b/apps/web/lib/auth/safe-callback.test.ts new file mode 100644 index 0000000..ce34c4b --- /dev/null +++ b/apps/web/lib/auth/safe-callback.test.ts @@ -0,0 +1,41 @@ +/** + * Regression: ISSUE-006 — /api/auth/signin rendered a blank page with no + * provider configured. The replacement /signin page accepts Auth.js's + * attacker-controlled `callbackUrl`, so it must never redirect off-origin. + * Found by /qa on 2026-08-01 + * Report: .gstack/qa-reports/qa-report-quorum-2026-08-01-signin.md + */ +import { describe, expect, it } from "vitest"; + +import { safeCallbackUrl } from "./safe-callback"; + +describe("safeCallbackUrl", () => { + it("keeps a same-origin path so sign-in returns you where you were", () => { + expect(safeCallbackUrl("/b/brd_abc123")).toBe("/b/brd_abc123"); + }); + + it("keeps the query string and hash on that path", () => { + expect(safeCallbackUrl("/b/brd_abc?tab=chat#msg-3")).toBe("/b/brd_abc?tab=chat#msg-3"); + }); + + it.each([undefined, null, ""])("falls back home for %j", (value) => { + expect(safeCallbackUrl(value)).toBe("/"); + }); + + it.each([ + "https://evil.example/steal", + "http://evil.example", + "javascript:alert(1)", + "data:text/html,", + "evil.example", + ])("refuses the absolute or scheme-bearing target %j", (value) => { + expect(safeCallbackUrl(value)).toBe("/"); + }); + + it.each(["//evil.example", "//evil.example/path", "/\\evil.example"])( + "refuses %j, which a browser reads as protocol-relative despite the leading slash", + (value) => { + expect(safeCallbackUrl(value)).toBe("/"); + }, + ); +}); From f532f3653ad0cf181362af28a978f048a4766683 Mon Sep 17 00:00:00 2001 From: muthanii Date: Sun, 2 Aug 2026 08:03:45 +0300 Subject: [PATCH 7/7] fix(ci): drop the pnpm version input that failed every run since CI landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI has never passed. All 7 runs died at step 3, pnpm/action-setup@v4, before install ever ran — so typecheck, lint, test and build have never executed on GitHub even once. action-setup@v4 throws when its `version` input disagrees with package.json's packageManager pin: if (version && packageManagerVersion && packageManagerVersion !== version) throw new Error("Multiple versions of pnpm specified: ...") The workflow said `version: 9`, package.json says `pnpm@9.15.4`, and "9.15.4" !== "9". Removing the input lets the action read the pin, which is also the behaviour we want: CI resolves the exact pnpm the lockfile was written with, and the version lives in one place. Verified the rest of the job locally with the root .env hidden, since CI has no .env and those steps had never run there: frozen-lockfile install is in sync, typecheck clean across all 7 packages, lint clean, 466 tests pass, and the web build succeeds on the workflow's own throwaway env. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbe13c1..71b63ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,12 @@ jobs: steps: - uses: actions/checkout@v4 + # No `version:` on purpose. action-setup@v4 throws "Multiple versions of + # pnpm specified" when its input disagrees with package.json's + # packageManager pin, and "9" !== "9.15.4". Reading the pin is also the + # behaviour we want: CI resolves the exact pnpm the lockfile was written + # with, and there is one place to bump it. - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: