From d7a8928c9f37b3a297ff57b1d44ac2dd51f7904d Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Mon, 6 Jul 2026 09:28:14 +1000 Subject: [PATCH 1/3] feat(hutch): auto-save the first article for new signups Only ~4 of 35 new users per month read anything, and 23 of 34 signups arrive without a pending save (they clicked a nav/homepage signup CTA, not "Save on the reader"). A new user who lands on an empty queue has nothing to do; one whose queue already holds the article that brought them has an immediate reason to come back. On an anonymous /view open (behind the same isbot gate as view_opened), set a 2h httpOnly hutch_lastview cookie with the article URL. On signup success, when the redirect would default to /queue (no explicit ?return=) and the cookie is present and passes validateSaveableUrl, redirect to /queue?url=
&utm_source=signup-autosave so the existing auto-submit save form drops the first article into the new queue. A shared resolvePostSignupRedirect helper keeps the three signup paths (email, Google, Apple) consistent; Apple tunnels the URL through its signed state because its cross-site form_post callback drops the Lax cookie. Cookie is cleared on consume and on logout; an explicit ?return= (the /save round-trip) always wins so nothing double-saves. Claude-Session: https://claude.ai/code/session_01En2uUiiwQoRQKWenCnkPi9 --- projects/hutch/src/runtime/server.ts | 1 + .../src/runtime/web/auth/apple-auth.page.ts | 16 ++- .../runtime/web/auth/apple-auth.route.test.ts | 68 ++++++++++++ .../hutch/src/runtime/web/auth/auth.page.ts | 9 +- .../src/runtime/web/auth/auth.route.test.ts | 104 ++++++++++++++++++ .../src/runtime/web/auth/google-auth.page.ts | 8 +- .../web/auth/google-auth.route.test.ts | 57 ++++++++++ .../web/auth/post-signup-redirect.test.ts | 35 ++++++ .../runtime/web/auth/post-signup-redirect.ts | 24 ++++ .../hutch/src/runtime/web/last-view.test.ts | 52 +++++++++ projects/hutch/src/runtime/web/last-view.ts | 40 +++++++ .../src/runtime/web/pages/view/view.page.ts | 5 + .../runtime/web/pages/view/view.route.test.ts | 46 ++++++++ 13 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts create mode 100644 projects/hutch/src/runtime/web/auth/post-signup-redirect.ts create mode 100644 projects/hutch/src/runtime/web/last-view.test.ts create mode 100644 projects/hutch/src/runtime/web/last-view.ts diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index a56cfb8a4..011668ffe 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -1059,6 +1059,7 @@ export function createApp(dependencies: AppDependencies): Express { const viewRouter = initViewRoutes({ validateSaveableUrl: deps.validateSaveableUrl, appOrigin, + secureCookies, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, readArticleContent: deps.readArticleContent, diff --git a/projects/hutch/src/runtime/web/auth/apple-auth.page.ts b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts index 563bb5153..721f31e76 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts @@ -35,6 +35,8 @@ import { LoginPage } from "./auth.component"; import { initFetchUserCount } from "./fetch-user-count"; import { ClickAttributionSchema, readClickAttribution } from "../click-attribution.middleware"; import { PENDING_SAVE_COOKIE_NAME, readPendingSaveId } from "../pending-save"; +import { LAST_VIEW_COOKIE_NAME, readLastViewUrl } from "../last-view"; +import { resolvePostSignupRedirect } from "./post-signup-redirect"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; import { signState, verifyState } from "./oauth-state"; @@ -59,6 +61,7 @@ const StatePayloadSchema = z.object({ attribution: ClickAttributionSchema.optional(), visitorId: z.string().optional(), pendingSaveId: z.string().optional(), + lastViewUrl: z.string().optional(), }); const STATE_COOKIE = "hutch_astate"; @@ -117,6 +120,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const attribution = readClickAttribution(req); const visitorId = req.visitorId; const pendingSaveId = readPendingSaveId(req); + const lastViewUrl = readLastViewUrl(req); const statePayload = JSON.stringify({ nonce, returnUrl, @@ -124,6 +128,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { ...(attribution ? { attribution } : {}), ...(visitorId ? { visitorId } : {}), ...(pendingSaveId ? { pendingSaveId } : {}), + ...(lastViewUrl ? { lastViewUrl } : {}), }); const signedState = signState({ payload: statePayload, secret: deps.stateSigningSecret }); @@ -237,6 +242,11 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { * the response Set-Cookie still applies to readplace.com — clear it so the * consumed save cannot re-attach to a later signup. */ const clearPendingSave = () => res.clearCookie(PENDING_SAVE_COOKIE_NAME, { path: "/" }); + /* Same rationale as clearPendingSave: hutch_lastview is same-site Lax so it + * is not sent on this cross-site POST, but the response Set-Cookie still + * applies to readplace.com — clear it so the tunneled URL cannot auto-save + * onto a later signup. */ + const clearLastView = () => res.clearCookie(LAST_VIEW_COOKIE_NAME, { path: "/" }); const userCount = await fetchUserCount(); if (!deps.foundingAllocation.isFoundingAllocationExhausted(userCount)) { @@ -268,6 +278,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const sessionId = await deps.createSession({ userId: created.userId, emailVerified: true }); res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions); clearPendingSave(); + clearLastView(); sendWelcomeEmail(tokenResult.email); emitUserCreated( { logger: deps.conversionLogger, now: deps.now }, @@ -279,7 +290,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { ...conversionContext, }, ); - res.redirect(303, parseReturnUrl({ return: safeReturnUrl })); + res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl })); return; } @@ -338,6 +349,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const sessionId = await deps.createSession({ userId: created.userId, emailVerified: true }); res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions); clearPendingSave(); + clearLastView(); sendWelcomeEmail(tokenResult.email); emitUserCreated( { logger: deps.conversionLogger, now: deps.now }, @@ -349,7 +361,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { ...conversionContext, }, ); - res.redirect(303, parseReturnUrl({ return: safeReturnUrl })); + res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl })); }); return router; diff --git a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts index 92533e814..03185592f 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts @@ -59,6 +59,7 @@ function freshState(overrides?: { attribution?: Record; visitorId?: string; pendingSaveId?: string; + lastViewUrl?: string; }) { return { nonce: "test-nonce", @@ -67,6 +68,7 @@ function freshState(overrides?: { ...(overrides?.attribution ? { attribution: overrides.attribution } : {}), ...(overrides?.visitorId ? { visitorId: overrides.visitorId } : {}), ...(overrides?.pendingSaveId ? { pendingSaveId: overrides.pendingSaveId } : {}), + ...(overrides?.lastViewUrl ? { lastViewUrl: overrides.lastViewUrl } : {}), }; } @@ -527,6 +529,72 @@ describe("Apple auth routes", () => { expect(passwordCheck.ok).toBe(true); }); + describe("first-article autosave", () => { + const ARTICLE_URL = "https://example.com/post"; + const AUTOSAVE_LOCATION = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`; + + it("tunnels the last-viewed url into the signed state at GET so it survives the cross-site callback", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith() }); + + const getResponse = await request(harness.server) + .get("/auth/apple") + .set("Cookie", `hutch_lastview=${encodeURIComponent(ARTICLE_URL)}`); + + expect(getResponse.status).toBe(303); + const state = readSetCookie(getResponse, "hutch_astate"); + assert(state, "GET must set a state cookie carrying the tunneled last-view url"); + const payload = JSON.parse(state.slice(0, state.lastIndexOf("."))); + expect(payload.lastViewUrl).toBe(ARTICLE_URL); + }); + + it("auto-saves the tunneled article for a new Apple user with no explicit return, and clears the cookie", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-apple@example.com" })) }); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState({ lastViewUrl: ARTICLE_URL })); + + const response = await postCallback(harness.server, { + state, + cookie: `hutch_astate=${encodeURIComponent(state)}`, + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe(AUTOSAVE_LOCATION); + expect(cookiesFrom(response).join(";")).toContain("hutch_lastview=;"); + }, 30000); + + it("lets an explicit return URL win over the tunneled autosave", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-return-apple@example.com" })) }); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState({ lastViewUrl: ARTICLE_URL, returnUrl: "/oauth/authorize?client_id=test" })); + + const response = await postCallback(harness.server, { + state, + cookie: `hutch_astate=${encodeURIComponent(state)}`, + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/oauth/authorize?client_id=test"); + }, 30000); + + it("redirects to a plain /queue when the state carries no last-view url", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-plain-apple@example.com" })) }); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState()); + + const response = await postCallback(harness.server, { + state, + cookie: `hutch_astate=${encodeURIComponent(state)}`, + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/queue"); + }, 30000); + }); + it("tunnels attribution, visitor id, and pending-save id captured at GET through the cross-site callback", async () => { const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "tunnel@example.com" })) }); diff --git a/projects/hutch/src/runtime/web/auth/auth.page.ts b/projects/hutch/src/runtime/web/auth/auth.page.ts index 0d26bcebe..86e71d717 100644 --- a/projects/hutch/src/runtime/web/auth/auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/auth.page.ts @@ -66,6 +66,8 @@ import { initValidateSignup } from "./validate-signup"; import type { FoundingAllocation } from "../shared/founding-progress/founding-allocation"; import { readClickAttribution } from "../click-attribution.middleware"; import { consumePendingSaveId } from "../pending-save"; +import { consumeLastViewUrl, LAST_VIEW_COOKIE_NAME } from "../last-view"; +import { resolvePostSignupRedirect } from "./post-signup-redirect"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; @@ -320,7 +322,8 @@ export function initAuthRoutes(deps: AuthDependencies): Router { pendingSaveId: consumePendingSaveId({ req, res }), }, ); - res.redirect(303, parseReturnUrl({ return: returnUrl })); + const lastViewUrl = consumeLastViewUrl({ req, res }); + res.redirect(303, resolvePostSignupRedirect({ returnUrl, lastViewUrl })); return; } @@ -375,7 +378,8 @@ export function initAuthRoutes(deps: AuthDependencies): Router { pendingSaveId: consumePendingSaveId({ req, res }), }, ); - res.redirect(303, parseReturnUrl({ return: returnUrl })); + const lastViewUrl = consumeLastViewUrl({ req, res }); + res.redirect(303, resolvePostSignupRedirect({ returnUrl, lastViewUrl })); }); router.get("/auth/checkout/success", async (req: Request, res: Response) => { @@ -510,6 +514,7 @@ export function initAuthRoutes(deps: AuthDependencies): Router { await deps.destroySession(sessionId); } res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); + res.clearCookie(LAST_VIEW_COOKIE_NAME, { path: "/" }); res.redirect(303, "/"); }); diff --git a/projects/hutch/src/runtime/web/auth/auth.route.test.ts b/projects/hutch/src/runtime/web/auth/auth.route.test.ts index 1bc05a448..8f65f6fae 100644 --- a/projects/hutch/src/runtime/web/auth/auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/auth.route.test.ts @@ -830,6 +830,104 @@ describe("Auth routes", () => { }); + describe("POST /signup — first-article autosave", () => { + const ARTICLE_URL = "https://example.com/post"; + const VIEW_PATH = "/view/example.com/post"; + const AUTOSAVE_LOCATION = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`; + + function lastViewSetCookie(response: request.Response): string | undefined { + const setCookie = response.headers["set-cookie"]; + const cookies = Array.isArray(setCookie) ? setCookie : []; + return cookies.find((c) => c.startsWith("hutch_lastview=")); + } + + it("redirects a free signup to auto-save the article the visitor just viewed, and clears the cookie", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const agent = request.agent(harness.server); + await agent.get(VIEW_PATH); + + const response = await agent.post("/signup").type("form").send({ + email: "autosave-free@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe(AUTOSAVE_LOCATION); + const cleared = lastViewSetCookie(response); + assert(cleared, "signup must clear hutch_lastview after consuming it"); + expect(cleared.startsWith("hutch_lastview=;")).toBe(true); + }, 30000); + + it("lets an explicit ?return= win over the autosave even when the cookie is present", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const agent = request.agent(harness.server); + await agent.get(VIEW_PATH); + + const response = await agent.post("/signup?return=%2Foauth%2Fauthorize").type("form").send({ + email: "autosave-return@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/oauth/authorize"); + }, 30000); + + it("redirects a free signup to a plain /queue when no hutch_lastview cookie is present", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + + const response = await request(harness.server).post("/signup").type("form").send({ + email: "autosave-none@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/queue"); + }, 30000); + + it("ignores a tampered hutch_lastview cookie and redirects to a plain /queue", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + + const response = await request(harness.server) + .post("/signup") + .type("form") + .set("Cookie", "hutch_lastview=not-a-url") + .send({ + email: "autosave-tampered@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/queue"); + }, 30000); + + it("auto-saves through the trial signup branch when the founding allocation is exhausted", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + for (let i = 0; i < TEST_FOUNDING_MEMBER_LIMIT; i++) { + await harness.auth.createUser({ email: `seed${i}@test.com`, password: "password123" }); + } + const agent = request.agent(harness.server); + await agent.get(VIEW_PATH); + + const response = await agent.post("/signup").type("form").send({ + email: "autosave-trial@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe(AUTOSAVE_LOCATION); + }, 30000); + }); + describe("POST /signup — bot defense", () => { it("returns a fake-success 303 to /?signup=pending and logs a 'honeypot' rejection when the hidden website field is filled", async () => { const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); @@ -1102,6 +1200,12 @@ describe("Auth routes", () => { expect(response.status).toBe(303); expect(response.headers.location).toBe("/"); + const setCookie = response.headers["set-cookie"]; + const cookies = Array.isArray(setCookie) ? setCookie : []; + assert( + cookies.some((c) => c.startsWith("hutch_lastview=;")), + "logout must clear hutch_lastview so it cannot auto-save onto a later signup", + ); }); it("should handle logout when no session cookie exists", async () => { diff --git a/projects/hutch/src/runtime/web/auth/google-auth.page.ts b/projects/hutch/src/runtime/web/auth/google-auth.page.ts index 92b36be3d..cf2d6f85c 100644 --- a/projects/hutch/src/runtime/web/auth/google-auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/google-auth.page.ts @@ -34,6 +34,8 @@ import { LoginPage } from "./auth.component"; import { initFetchUserCount } from "./fetch-user-count"; import { readClickAttribution } from "../click-attribution.middleware"; import { consumePendingSaveId } from "../pending-save"; +import { consumeLastViewUrl } from "../last-view"; +import { resolvePostSignupRedirect } from "./post-signup-redirect"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; import { signState, verifyState } from "./oauth-state"; @@ -218,7 +220,8 @@ export const initGoogleAuthRoutes = (deps: GoogleAuthDependencies): Router => { pendingSaveId: consumePendingSaveId({ req, res }), }, ); - res.redirect(303, parseReturnUrl({ return: safeReturnUrl })); + const lastViewUrl = consumeLastViewUrl({ req, res }); + res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl })); return; } @@ -284,7 +287,8 @@ export const initGoogleAuthRoutes = (deps: GoogleAuthDependencies): Router => { pendingSaveId: consumePendingSaveId({ req, res }), }, ); - res.redirect(303, parseReturnUrl({ return: safeReturnUrl })); + const lastViewUrl = consumeLastViewUrl({ req, res }); + res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl })); }); return router; diff --git a/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts b/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts index 877e726bd..29147778b 100644 --- a/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts @@ -243,6 +243,63 @@ describe("Google auth routes", () => { expect(consumed).toBeNull(); }, 30000); + describe("first-article autosave", () => { + const ARTICLE_URL = "https://example.com/post"; + const AUTOSAVE_LOCATION = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`; + + function newUserHarness() { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + return useApp({ + ...fixture, + google: { + exchangeGoogleCode: stubExchange({ email: "autosave-google@example.com" }), + clientId: TEST_CLIENT_ID, + clientSecret: TEST_CLIENT_SECRET, + }, + }); + } + + it("auto-saves the last-viewed article for a new Google user with no explicit return", async () => { + const harness = newUserHarness(); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState()); + + const response = await request(harness.server) + .get(`/auth/google/callback?code=test-code&state=${encodeURIComponent(state)}`) + .set("Cookie", `hutch_gstate=${encodeURIComponent(state)}; hutch_lastview=${encodeURIComponent(ARTICLE_URL)}`); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe(AUTOSAVE_LOCATION); + expect(cookiesFrom(response).join(";")).toContain("hutch_lastview=;"); + }, 30000); + + it("lets an explicit return URL win over the autosave", async () => { + const harness = newUserHarness(); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState({ returnUrl: "/oauth/authorize?client_id=test" })); + + const response = await request(harness.server) + .get(`/auth/google/callback?code=test-code&state=${encodeURIComponent(state)}`) + .set("Cookie", `hutch_gstate=${encodeURIComponent(state)}; hutch_lastview=${encodeURIComponent(ARTICLE_URL)}`); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/oauth/authorize?client_id=test"); + }, 30000); + + it("redirects to a plain /queue when no hutch_lastview cookie is present", async () => { + const harness = newUserHarness(); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState()); + + const response = await request(harness.server) + .get(`/auth/google/callback?code=test-code&state=${encodeURIComponent(state)}`) + .set("Cookie", `hutch_gstate=${encodeURIComponent(state)}`); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/queue"); + }, 30000); + }); + it("logs in the existing user when a race condition causes createGoogleUser to fail during free signup", async () => { const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); let raceFindCount = 0; diff --git a/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts b/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts new file mode 100644 index 000000000..b0a1fffa5 --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts @@ -0,0 +1,35 @@ +import { resolvePostSignupRedirect } from "./post-signup-redirect"; + +const ARTICLE_URL = "https://example.com/post"; +const AUTOSAVE = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`; + +describe("resolvePostSignupRedirect", () => { + it("honours an explicit returnUrl and never auto-saves, even when a lastViewUrl is present", () => { + expect( + resolvePostSignupRedirect({ returnUrl: "/oauth/authorize?client_id=test", lastViewUrl: ARTICLE_URL }), + ).toBe("/oauth/authorize?client_id=test"); + }); + + it("falls back to /queue when there is neither a returnUrl nor a lastViewUrl", () => { + expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: undefined })).toBe("/queue"); + }); + + it("auto-saves the last-viewed article when there is no explicit returnUrl", () => { + expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: ARTICLE_URL })).toBe(AUTOSAVE); + }); + + it("carries the utm_source marker and url-encodes the article url", () => { + const location = resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: ARTICLE_URL }); + const params = new URL(location, "http://localhost").searchParams; + expect(params.get("url")).toBe(ARTICLE_URL); + expect(params.get("utm_source")).toBe("signup-autosave"); + }); + + it.each([ + ["a non-url", "not a url"], + ["a javascript: scheme", "javascript:alert(1)"], + ["a loopback address", "http://localhost"], + ])("ignores %s lastViewUrl and falls back to /queue", (_label, tampered) => { + expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: tampered })).toBe("/queue"); + }); +}); diff --git a/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts b/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts new file mode 100644 index 000000000..832be0dd1 --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts @@ -0,0 +1,24 @@ +import { validateSaveableUrl } from "@packages/domain/article"; +import { parseReturnUrl } from "./parse-return-url"; + +const AUTOSAVE_MARKER: [string, string] = ["utm_source", "signup-autosave"]; + +/** Where a fresh signup lands. An explicit `?return=` always wins (the /save + * round-trip already carries and saves the held article in that case, so + * auto-saving too would double-save). Otherwise, when the anonymous visitor was + * reading an article just before signing up, redirect through /queue?url=… so + * the auto-submit save form drops that first article into the new empty queue. + * The URL is re-validated with the same validator used everywhere else so a + * tampered cookie can never reach the save. */ +export function resolvePostSignupRedirect(params: { + returnUrl: string | undefined; + lastViewUrl: string | undefined; +}): string { + const fallback = parseReturnUrl({ return: params.returnUrl }); + if (params.returnUrl !== undefined) return fallback; + if (params.lastViewUrl === undefined) return fallback; + const validation = validateSaveableUrl(params.lastViewUrl); + if (validation.status === "ERROR") return fallback; + const qs = new URLSearchParams([["url", validation.url], AUTOSAVE_MARKER]); + return `/queue?${qs.toString()}`; +} diff --git a/projects/hutch/src/runtime/web/last-view.test.ts b/projects/hutch/src/runtime/web/last-view.test.ts new file mode 100644 index 000000000..8fc4d51ff --- /dev/null +++ b/projects/hutch/src/runtime/web/last-view.test.ts @@ -0,0 +1,52 @@ +import { LAST_VIEW_COOKIE_NAME, consumeLastViewUrl, readLastViewUrl } from "./last-view"; + +const ARTICLE_URL = "https://example.com/post"; + +function recordingRes(): { + res: { clearCookie: (name: string, options: { path: string }) => void }; + cleared: { name: string; options: { path: string } }[]; +} { + const cleared: { name: string; options: { path: string } }[] = []; + return { + res: { clearCookie: (name, options) => { cleared.push({ name, options }); } }, + cleared, + }; +} + +describe("readLastViewUrl", () => { + it("returns the url from a present cookie", () => { + expect(readLastViewUrl({ cookies: { [LAST_VIEW_COOKIE_NAME]: ARTICLE_URL } })).toBe(ARTICLE_URL); + }); + + it("returns undefined when there is no cookie jar", () => { + expect(readLastViewUrl({})).toBeUndefined(); + }); + + it("returns undefined when the cookie is absent", () => { + expect(readLastViewUrl({ cookies: {} })).toBeUndefined(); + }); + + it("treats a non-string cookie as absent", () => { + expect(readLastViewUrl({ cookies: { [LAST_VIEW_COOKIE_NAME]: 42 } })).toBeUndefined(); + }); +}); + +describe("consumeLastViewUrl", () => { + it("returns the url and clears the cookie so it auto-saves exactly once", () => { + const { res, cleared } = recordingRes(); + + const url = consumeLastViewUrl({ req: { cookies: { [LAST_VIEW_COOKIE_NAME]: ARTICLE_URL } }, res }); + + expect(url).toBe(ARTICLE_URL); + expect(cleared).toEqual([{ name: LAST_VIEW_COOKIE_NAME, options: { path: "/" } }]); + }); + + it("clears the cookie even when none is present so a stale value cannot linger", () => { + const { res, cleared } = recordingRes(); + + const url = consumeLastViewUrl({ req: { cookies: {} }, res }); + + expect(url).toBeUndefined(); + expect(cleared).toEqual([{ name: LAST_VIEW_COOKIE_NAME, options: { path: "/" } }]); + }); +}); diff --git a/projects/hutch/src/runtime/web/last-view.ts b/projects/hutch/src/runtime/web/last-view.ts new file mode 100644 index 000000000..a9a8df249 --- /dev/null +++ b/projects/hutch/src/runtime/web/last-view.ts @@ -0,0 +1,40 @@ +import type { Response } from "express"; +import { baseCookieOptions } from "./cookie-options"; + +export const LAST_VIEW_COOKIE_NAME = "hutch_lastview"; + +/** The cookie is cleared the instant it is consumed at signup (see + * consumeLastViewUrl), so the TTL only has to bound an *abandoned* anonymous + * view — one whose visitor read an article but never signed up. Two hours is + * long enough for a reader to finish the article and decide to sign up, yet + * short enough that a stale last-view cannot linger and auto-save itself onto an + * unrelated later signup on the same browser. */ +const LAST_VIEW_COOKIE_MAX_AGE_MS = 2 * 60 * 60 * 1000; + +export function setLastViewUrl(deps: { res: Response; secure: boolean }, url: string): void { + deps.res.cookie(LAST_VIEW_COOKIE_NAME, url, { + ...baseCookieOptions(deps.secure), + maxAge: LAST_VIEW_COOKIE_MAX_AGE_MS, + }); +} + +export function readLastViewUrl(req: { cookies?: Record }): string | undefined { + const raw = req.cookies?.[LAST_VIEW_COOKIE_NAME]; + if (typeof raw !== "string") return undefined; + return raw; +} + +/** Reads the last-viewed URL and clears the cookie in the same step so it can + * auto-save exactly once. Without clearing, a second signup on the same browser + * within the cookie's TTL would inherit the first visitor's article. The clear + * is unconditional (matching the pending-save/logout/oauth-state cookie clears) + * and uses path "/" to match the cookie `setLastViewUrl` writes via + * `baseCookieOptions`. */ +export function consumeLastViewUrl(deps: { + req: { cookies?: Record }; + res: { clearCookie: (name: string, options: { path: string }) => void }; +}): string | undefined { + const url = readLastViewUrl(deps.req); + deps.res.clearCookie(LAST_VIEW_COOKIE_NAME, { path: "/" }); + return url; +} diff --git a/projects/hutch/src/runtime/web/pages/view/view.page.ts b/projects/hutch/src/runtime/web/pages/view/view.page.ts index 58c632985..45592b7d0 100644 --- a/projects/hutch/src/runtime/web/pages/view/view.page.ts +++ b/projects/hutch/src/runtime/web/pages/view/view.page.ts @@ -41,6 +41,7 @@ import { Base } from "../../base.component"; import type { BuildBannerState } from "../../banner-state"; import { extensionInstallUrlIfMissing, isExtensionInstalled } from "../../onboarding/extension-install"; +import { setLastViewUrl } from "../../last-view"; import { initArticleReader } from "../../shared/article-reader/article-reader"; import type { ArticleReaderDeps, @@ -58,6 +59,7 @@ import { ViewPage, formatViewDocumentTitle, type ViewAction } from "./view.compo interface ViewDependencies { validateSaveableUrl: ValidateSaveableUrl; appOrigin: string; + secureCookies: boolean; findArticleByUrl: FindArticleByUrl; findArticleFreshness: FindArticleFreshness; readArticleContent: ReadArticleContent; @@ -237,6 +239,9 @@ function handleViewArticle(deps: ViewDependencies, reader: ReturnType { }); }); + describe("hutch_lastview cookie for first-article autosave", () => { + function lastViewCookie(response: request.Response): string | undefined { + const setCookie = response.headers["set-cookie"]; + const cookies = Array.isArray(setCookie) ? setCookie : []; + return cookies.find((c) => c.startsWith("hutch_lastview=")); + } + + it("sets hutch_lastview to the article url on an anonymous, non-bot open", async () => { + const harness = buildReaderHarness(); + + const response = await request(harness.server).get(`/view/${CANONICAL_PATH}`); + + expect(response.status).toBe(200); + const cookie = lastViewCookie(response); + assert(cookie, "an anonymous open must set hutch_lastview"); + expect(decodeURIComponent(cookie.slice("hutch_lastview=".length).split(";")[0])).toBe(ARTICLE_URL); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + expect(cookie).toContain("Max-Age=7200"); + expect(cookie).toContain("Path=/"); + }); + + it("does not set hutch_lastview when an authenticated viewer opens the reader", async () => { + const harness = buildReaderHarness(); + await harness.auth.createUser({ email: "reader2@example.com", password: "password123" }); + const agent = request.agent(harness.server); + await agent.post("/login").type("form").send({ email: "reader2@example.com", password: "password123" }); + + const response = await agent.get(`/view/${CANONICAL_PATH}`); + + expect(response.status).toBe(200); + expect(lastViewCookie(response)).toBeUndefined(); + }); + + it("does not set hutch_lastview for a bot user-agent (behind the same gate as view_opened)", async () => { + const harness = buildReaderHarness(); + + const response = await request(harness.server) + .get(`/view/${CANONICAL_PATH}`) + .set("User-Agent", GOOGLEBOT); + + expect(response.status).toBe(200); + expect(lastViewCookie(response)).toBeUndefined(); + }); + }); + describe("prefetch and bot requests do not trigger the paid crawl", () => { it("skips the crawl cascade for a Sec-Purpose: prefetch request", async () => { const harness = buildReaderHarness(); From 1822e502f15aed8a8178ad32a56a6f36b8b07a01 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Sat, 11 Jul 2026 14:21:13 +1000 Subject: [PATCH 2/3] fix(hutch): repoint last-view cookie options at @packages/web-analytics Main deleted the local cookie-options module when the web-analytics package extraction landed; baseCookieOptions lives there now. --- projects/hutch/src/runtime/web/last-view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/hutch/src/runtime/web/last-view.ts b/projects/hutch/src/runtime/web/last-view.ts index a9a8df249..4de293e7f 100644 --- a/projects/hutch/src/runtime/web/last-view.ts +++ b/projects/hutch/src/runtime/web/last-view.ts @@ -1,5 +1,5 @@ import type { Response } from "express"; -import { baseCookieOptions } from "./cookie-options"; +import { baseCookieOptions } from "@packages/web-analytics"; export const LAST_VIEW_COOKIE_NAME = "hutch_lastview"; From ebee729cb11f195bbea035500f77700051bfa74c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:06:08 +0000 Subject: [PATCH 3/3] feat(hutch): discrete first-article-autosave event + Apple state-cookie guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the standing low-priority review notes on PR #943. #3 — measurement no longer rides a utm_source string. Emit a server-side first_article_autosaved analytics event at the post-signup redirect decision across all three signup paths (email, Google, Apple; free + trial), exactly once per trigger and immune to the reload/share recounting that the utm_source=signup-autosave pageview marker is prone to. resolvePostSignupRedirect now returns the autosaved url alongside the location so the emit stays 1:1 with the decision. Surfaced on a new "First-article autosaves per day" dashboard widget (widget count 28 -> 29). The utm_source marker is retained as a harmless secondary tag on the pageview. #1 — guard the Apple hutch_astate cookie size. signAppleState tunnels the last-view URL only while the serialized (encodeURIComponent) signed state stays under a 4 KB-safe budget; past it the URL is dropped and autosave degrades to a plain /queue rather than the whole state cookie being lost (which would drop the nonce and break sign-in). Co-authored-by: Fayner Brack Co-Authored-By: Claude Opus 4.8 (1M context) --- .../observability/analytics-dashboard.test.ts | 12 ++++- .../observability/analytics-dashboard.ts | 23 ++++++++++ projects/hutch/src/runtime/server.ts | 3 ++ .../src/runtime/web/auth/apple-auth.page.ts | 40 ++++++++++++----- .../runtime/web/auth/apple-auth.route.test.ts | 32 +++++++++++++ .../src/runtime/web/auth/apple-state.test.ts | 34 ++++++++++++++ .../hutch/src/runtime/web/auth/apple-state.ts | 45 +++++++++++++++++++ .../hutch/src/runtime/web/auth/auth.page.ts | 17 ++++++- .../src/runtime/web/auth/auth.route.test.ts | 29 ++++++++++++ .../web/auth/first-article-autosaved.ts | 32 +++++++++++++ .../src/runtime/web/auth/google-auth.page.ts | 17 ++++++- .../web/auth/google-auth.route.test.ts | 12 +++++ .../web/auth/post-signup-redirect.test.ts | 21 ++++++--- .../runtime/web/auth/post-signup-redirect.ts | 21 ++++++--- src/packages/web-analytics/src/analytics.ts | 21 ++++++++- src/packages/web-analytics/src/events.ts | 1 + src/packages/web-analytics/src/index.ts | 1 + 17 files changed, 330 insertions(+), 31 deletions(-) create mode 100644 projects/hutch/src/runtime/web/auth/apple-state.test.ts create mode 100644 projects/hutch/src/runtime/web/auth/apple-state.ts create mode 100644 projects/hutch/src/runtime/web/auth/first-article-autosaved.ts diff --git a/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts b/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts index 8f84841f6..c0e16af81 100644 --- a/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts +++ b/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts @@ -70,9 +70,17 @@ function collectReferencedEvents(): Set { } describe("buildAnalyticsDashboardBody — drift prevention", () => { - it("emits 28 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 3 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => { + it("emits 29 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 3 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic, 1 first-article-autosave) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => { const body = buildBody(); - expect(body.widgets).toHaveLength(28); + expect(body.widgets).toHaveLength(29); + }); + + it("the first-article-autosave widget counts the discrete first_article_autosaved event per day — a 1:1 activation signal independent of the utm_source marker", () => { + const queries = widgetQueries(); + const autosave = queries.find((q) => q.includes(`event = "${ANALYTICS_EVENTS.firstArticleAutosaved}"`)); + expect(autosave).toBeDefined(); + expect(autosave).toContain(`stream = "${STREAMS.analytics}"`); + expect(autosave).toContain("stats count(*) as autosaves by bin(1d)"); }); it("the homepage A/B widget compares arms by distinct visitors (assignment is sticky per browser, so raw counts pile a returning visitor's landings onto one arm) with raw landings alongside, grouped by variant (utm_content)", () => { diff --git a/projects/hutch/src/runtime/observability/analytics-dashboard.ts b/projects/hutch/src/runtime/observability/analytics-dashboard.ts index 0fc2b1bfc..58d38aab2 100644 --- a/projects/hutch/src/runtime/observability/analytics-dashboard.ts +++ b/projects/hutch/src/runtime/observability/analytics-dashboard.ts @@ -618,6 +618,29 @@ export function buildAnalyticsDashboardBody(deps: BuildAnalyticsDashboardDeps): }), ); + // --- First-article autosave (activation) --- + // Emitted server-side exactly once when a new signup's first article is + // auto-saved into their otherwise-empty queue. A discrete 1:1 activation + // count that does not depend on the utm_source=signup-autosave marker + // surviving on the post-signup /queue pageview (a reload or share of that URL + // would recount the marker). No exclude clause: the event carries no + // visitor_hash, like the conversion widgets. + + widgets.push( + logWidget({ + region, + title: "First-article autosaves per day", + logGroupNames: [hutchLogGroupName], + query: [ + "fields @timestamp, user_id, visitor_id", + `| filter stream = "${STREAMS.analytics}" and event = "${ANALYTICS_EVENTS.firstArticleAutosaved}"`, + "| stats count(*) as autosaves by bin(1d)", + ].join(" "), + x: 0, y: 130, width: 12, height: 8, + view: "timeSeries", + }), + ); + return { widgets }; } diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index 4de84ecd1..abcf743b0 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -885,6 +885,7 @@ export function createApp(dependencies: AppDependencies): Express { now: deps.now, botDefenseLogger: deps.botDefenseLogger, conversionLogger: deps.conversionLogger, + analytics: deps.analytics, foundingAllocation, buildBannerState, consumeRateLimit: deps.consumeRateLimit, @@ -918,6 +919,7 @@ export function createApp(dependencies: AppDependencies): Express { logError: deps.logError, now: deps.now, conversionLogger: deps.conversionLogger, + analytics: deps.analytics, foundingAllocation, }); app.use(googleAuthRouter); @@ -944,6 +946,7 @@ export function createApp(dependencies: AppDependencies): Express { logError: deps.logError, now: deps.now, conversionLogger: deps.conversionLogger, + analytics: deps.analytics, foundingAllocation, }); app.use(appleAuthRouter); diff --git a/projects/hutch/src/runtime/web/auth/apple-auth.page.ts b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts index 273363b89..2ec8a9286 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts @@ -30,6 +30,7 @@ import { bannerStateFromRequest, sendComponent } from "@packages/web-shell"; import { extractReturnUrl, parseReturnUrl } from "./parse-return-url"; import { baseCookieOptions } from "@packages/web-analytics"; +import type { AnalyticsEvent } from "@packages/web-analytics"; import { SESSION_COOKIE_NAME } from "@packages/web-session"; import { LoginPage } from "./auth.component"; import { initFetchUserCount } from "./fetch-user-count"; @@ -37,9 +38,11 @@ import { ClickAttributionSchema, readClickAttribution } from "@packages/web-anal import { PENDING_SAVE_COOKIE_NAME, readPendingSaveId } from "../pending-save"; import { LAST_VIEW_COOKIE_NAME, readLastViewUrl } from "../last-view"; import { resolvePostSignupRedirect } from "./post-signup-redirect"; +import { emitFirstArticleAutosaved } from "./first-article-autosaved"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; -import { signState, verifyState } from "./oauth-state"; +import { verifyState } from "./oauth-state"; +import { signAppleState } from "./apple-state"; const CallbackBodySchema = z.object({ code: z.string().min(1), @@ -88,6 +91,7 @@ interface AppleAuthDependencies { logError: (message: string, error?: Error) => void; now: () => Date; conversionLogger: HutchLogger.Typed; + analytics: HutchLogger.Typed; foundingAllocation: FoundingAllocation; } @@ -121,16 +125,18 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const visitorId = req.visitorId; const pendingSaveId = readPendingSaveId(req); const lastViewUrl = readLastViewUrl(req); - const statePayload = JSON.stringify({ - nonce, - returnUrl, - createdAt, - ...(attribution ? { attribution } : {}), - ...(visitorId ? { visitorId } : {}), - ...(pendingSaveId ? { pendingSaveId } : {}), - ...(lastViewUrl ? { lastViewUrl } : {}), + const signedState = signAppleState({ + payload: { + nonce, + returnUrl, + createdAt, + ...(attribution ? { attribution } : {}), + ...(visitorId ? { visitorId } : {}), + ...(pendingSaveId ? { pendingSaveId } : {}), + }, + lastViewUrl, + secret: deps.stateSigningSecret, }); - const signedState = signState({ payload: statePayload, secret: deps.stateSigningSecret }); res.cookie(STATE_COOKIE, signedState, { ...stateCookieOptions, @@ -290,7 +296,12 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { ...conversionContext, }, ); - res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl })); + const redirect = resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl }); + emitFirstArticleAutosaved( + { logger: deps.analytics, now: deps.now }, + { autosavedUrl: redirect.autosavedUrl, userId: created.userId, visitorId: stateData.visitorId }, + ); + res.redirect(303, redirect.location); return; } @@ -361,7 +372,12 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { ...conversionContext, }, ); - res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl })); + const redirect = resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl }); + emitFirstArticleAutosaved( + { logger: deps.analytics, now: deps.now }, + { autosavedUrl: redirect.autosavedUrl, userId: created.userId, visitorId: stateData.visitorId }, + ); + res.redirect(303, redirect.location); }); return router; diff --git a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts index 03185592f..77c86b891 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts @@ -10,6 +10,8 @@ import { import { AppleIdSchema } from "@packages/test-fixtures/providers/apple-auth"; import type { ExchangeAppleCode } from "@packages/test-fixtures/providers/apple-auth"; +import { ANALYTICS_EVENTS } from "@packages/web-analytics"; +import { MAX_APPLE_STATE_COOKIE_BYTES } from "./apple-state"; const TEST_FOUNDING_MEMBER_LIMIT = 3; @@ -562,8 +564,38 @@ describe("Apple auth routes", () => { expect(response.status).toBe(303); expect(response.headers.location).toBe(AUTOSAVE_LOCATION); expect(cookiesFrom(response).join(";")).toContain("hutch_lastview=;"); + + const autosaves = harness.analytics.events.filter( + (e) => e.event === ANALYTICS_EVENTS.firstArticleAutosaved, + ); + expect(autosaves).toHaveLength(1); + expect(autosaves[0]).toMatchObject({ + event: ANALYTICS_EVENTS.firstArticleAutosaved, + article_host: "example.com", + user_id: expect.any(String), + }); + // No visitor id was tunneled in this state, so the event omits it. + expect(autosaves[0]).not.toHaveProperty("visitor_id"); }, 30000); + it("does not tunnel a pathologically long last-view url that would overflow the state cookie", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith() }); + const hugeUrl = `https://example.com/${"a".repeat(MAX_APPLE_STATE_COOKIE_BYTES + 200)}`; + + const getResponse = await request(harness.server) + .get("/auth/apple") + .set("Cookie", `hutch_lastview=${encodeURIComponent(hugeUrl)}`); + + expect(getResponse.status).toBe(303); + const state = readSetCookie(getResponse, "hutch_astate"); + assert(state, "GET must still set a state cookie with the oversized url dropped"); + const payload = JSON.parse(state.slice(0, state.lastIndexOf("."))); + expect(payload.lastViewUrl).toBeUndefined(); + // The nonce (and the rest of the load-bearing state) survives. + expect(payload.nonce).toBeDefined(); + }); + it("lets an explicit return URL win over the tunneled autosave", async () => { const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-return-apple@example.com" })) }); diff --git a/projects/hutch/src/runtime/web/auth/apple-state.test.ts b/projects/hutch/src/runtime/web/auth/apple-state.test.ts new file mode 100644 index 000000000..c36c80497 --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/apple-state.test.ts @@ -0,0 +1,34 @@ +import { MAX_APPLE_STATE_COOKIE_BYTES, signAppleState } from "./apple-state"; +import { verifyState } from "./oauth-state"; + +const SECRET = "test-apple-state-secret"; + +function payloadOf(signed: string): Record { + const verified = verifyState({ signed, secret: SECRET }); + if (verified === null) throw new Error("signed state failed verification"); + return JSON.parse(verified); +} + +describe("signAppleState", () => { + const base = { nonce: "abc", returnUrl: undefined, createdAt: 1700000000000 }; + + it("omits lastViewUrl entirely when there is none to tunnel", () => { + const signed = signAppleState({ payload: base, lastViewUrl: undefined, secret: SECRET }); + expect(payloadOf(signed).lastViewUrl).toBeUndefined(); + }); + + it("tunnels a normal-length lastViewUrl into the signed state", () => { + const url = "https://example.com/post"; + const signed = signAppleState({ payload: base, lastViewUrl: url, secret: SECRET }); + expect(payloadOf(signed).lastViewUrl).toBe(url); + expect(Buffer.byteLength(encodeURIComponent(signed))).toBeLessThanOrEqual(MAX_APPLE_STATE_COOKIE_BYTES); + }); + + it("drops the lastViewUrl (degrading to no autosave) when tunneling it would overflow the cookie budget", () => { + const hugeUrl = `https://example.com/${"a".repeat(MAX_APPLE_STATE_COOKIE_BYTES + 200)}`; + const signed = signAppleState({ payload: base, lastViewUrl: hugeUrl, secret: SECRET }); + expect(payloadOf(signed).lastViewUrl).toBeUndefined(); + // The other load-bearing fields survive so sign-in still works. + expect(payloadOf(signed).nonce).toBe("abc"); + }); +}); diff --git a/projects/hutch/src/runtime/web/auth/apple-state.ts b/projects/hutch/src/runtime/web/auth/apple-state.ts new file mode 100644 index 000000000..c180577e8 --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/apple-state.ts @@ -0,0 +1,45 @@ +import { signState } from "./oauth-state"; + +/** + * Browsers cap a single cookie (name=value) near 4 KB and silently drop anything + * larger. Apple's signed acquisition state rides in the `hutch_astate` cookie, so + * the tunneled last-view article URL — the one field with no length bound + * (`validateSaveableUrl` accepts arbitrarily long tracking-laden URLs) — is + * included only while the *serialized* state stays under this budget. Past it the + * URL is dropped and first-article autosave degrades to a plain `/queue`, rather + * than the whole state cookie being lost (which would drop the nonce and break + * Apple sign-in). 4000 leaves headroom under 4096 for the `hutch_astate=` name. + */ +export const MAX_APPLE_STATE_COOKIE_BYTES = 4000; + +/** Express serializes a cookie value with `encodeURIComponent`, so the JSON + * braces/quotes/colons and the URL's `:`/`/`/`?`/`&` triple in size on the wire. + * The browser's per-cookie limit applies to that encoded form, so the budget is + * measured against it — not the raw signed string, which would undercount. */ +function serializedCookieBytes(value: string): number { + return Buffer.byteLength(encodeURIComponent(value)); +} + +/** + * Signs the Apple OAuth state, tunneling the last-view URL for first-article + * autosave only when doing so keeps the serialized cookie within the browser's + * per-cookie budget. Every other tunneled field (nonce, returnUrl, attribution, + * visitorId, pendingSaveId) is load-bearing for the callback and always kept; + * the last-view URL is the sole optional, unbounded addition, so it is the only + * one dropped under pressure. + */ +export function signAppleState(params: { + payload: Record; + lastViewUrl: string | undefined; + secret: string; +}): string { + if (params.lastViewUrl === undefined) { + return signState({ payload: JSON.stringify(params.payload), secret: params.secret }); + } + const withLastView = signState({ + payload: JSON.stringify({ ...params.payload, lastViewUrl: params.lastViewUrl }), + secret: params.secret, + }); + if (serializedCookieBytes(withLastView) <= MAX_APPLE_STATE_COOKIE_BYTES) return withLastView; + return signState({ payload: JSON.stringify(params.payload), secret: params.secret }); +} diff --git a/projects/hutch/src/runtime/web/auth/auth.page.ts b/projects/hutch/src/runtime/web/auth/auth.page.ts index 816906578..32cc01330 100644 --- a/projects/hutch/src/runtime/web/auth/auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/auth.page.ts @@ -66,9 +66,11 @@ import { createBotDefenseEvent } from "./bot-defense-event"; import { initValidateSignup } from "./validate-signup"; import type { FoundingAllocation } from "../shared/founding-progress/founding-allocation"; import { readClickAttribution } from "@packages/web-analytics"; +import type { AnalyticsEvent } from "@packages/web-analytics"; import { consumePendingSaveId } from "../pending-save"; import { consumeLastViewUrl, LAST_VIEW_COOKIE_NAME } from "../last-view"; import { resolvePostSignupRedirect } from "./post-signup-redirect"; +import { emitFirstArticleAutosaved } from "./first-article-autosaved"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; @@ -114,6 +116,7 @@ interface AuthDependencies { now: () => Date; botDefenseLogger: HutchLogger.Typed; conversionLogger: HutchLogger.Typed; + analytics: HutchLogger.Typed; foundingAllocation: FoundingAllocation; buildBannerState: BuildBannerState; consumeRateLimit: ConsumeRateLimit; @@ -337,7 +340,12 @@ export function initAuthRoutes(deps: AuthDependencies): Router { }, ); const lastViewUrl = consumeLastViewUrl({ req, res }); - res.redirect(303, resolvePostSignupRedirect({ returnUrl, lastViewUrl })); + const redirect = resolvePostSignupRedirect({ returnUrl, lastViewUrl }); + emitFirstArticleAutosaved( + { logger: deps.analytics, now: deps.now }, + { autosavedUrl: redirect.autosavedUrl, userId: created.userId, visitorId: req.visitorId }, + ); + res.redirect(303, redirect.location); return; } @@ -393,7 +401,12 @@ export function initAuthRoutes(deps: AuthDependencies): Router { }, ); const lastViewUrl = consumeLastViewUrl({ req, res }); - res.redirect(303, resolvePostSignupRedirect({ returnUrl, lastViewUrl })); + const redirect = resolvePostSignupRedirect({ returnUrl, lastViewUrl }); + emitFirstArticleAutosaved( + { logger: deps.analytics, now: deps.now }, + { autosavedUrl: redirect.autosavedUrl, userId: created.userId, visitorId: req.visitorId }, + ); + res.redirect(303, redirect.location); }); router.get("/auth/checkout/success", async (req: Request, res: Response) => { diff --git a/projects/hutch/src/runtime/web/auth/auth.route.test.ts b/projects/hutch/src/runtime/web/auth/auth.route.test.ts index 75c299a1c..c3b55878e 100644 --- a/projects/hutch/src/runtime/web/auth/auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/auth.route.test.ts @@ -15,6 +15,7 @@ import { AppleTokenResponse } from "../../providers/apple-auth/apple-token"; import { DISPOSABLE_EMAIL_MESSAGE } from "./disposable-email"; import { SIGNUP_MIN_SUBMIT_MS } from "./validate-signup"; import { SESSION_COOKIE_NAME, SESSION_TTL_SECONDS } from "@packages/web-session"; +import { ANALYTICS_EVENTS } from "@packages/web-analytics"; const TEST_FOUNDING_MEMBER_LIMIT = 3; @@ -858,6 +859,31 @@ describe("Auth routes", () => { expect(cleared.startsWith("hutch_lastview=;")).toBe(true); }, 30000); + it("emits a single discrete first_article_autosaved event carrying the new user and article host", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const agent = request.agent(harness.server); + await agent.get(VIEW_PATH); + + await agent.post("/signup").type("form").send({ + email: "autosave-event@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + const autosaves = harness.analytics.events.filter( + (e) => e.event === ANALYTICS_EVENTS.firstArticleAutosaved, + ); + expect(autosaves).toHaveLength(1); + expect(autosaves[0]).toMatchObject({ + stream: "analytics", + event: ANALYTICS_EVENTS.firstArticleAutosaved, + article_host: "example.com", + user_id: expect.any(String), + visitor_id: expect.any(String), + }); + }, 30000); + it("lets an explicit ?return= win over the autosave even when the cookie is present", async () => { const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); const agent = request.agent(harness.server); @@ -872,6 +898,9 @@ describe("Auth routes", () => { expect(response.status).toBe(303); expect(response.headers.location).toBe("/oauth/authorize"); + expect( + harness.analytics.events.filter((e) => e.event === ANALYTICS_EVENTS.firstArticleAutosaved), + ).toHaveLength(0); }, 30000); it("redirects a free signup to a plain /queue when no hutch_lastview cookie is present", async () => { diff --git a/projects/hutch/src/runtime/web/auth/first-article-autosaved.ts b/projects/hutch/src/runtime/web/auth/first-article-autosaved.ts new file mode 100644 index 000000000..168c03aa7 --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/first-article-autosaved.ts @@ -0,0 +1,32 @@ +import type { HutchLogger } from "@packages/hutch-logger"; +import type { UserId } from "@packages/domain/user"; +import { + type AnalyticsEvent, + articleHostFrom, + type FirstArticleAutosavedEvent, +} from "@packages/web-analytics"; +import { ANALYTICS_EVENTS, STREAMS } from "../../observability/events"; + +/** + * Emits the discrete `first_article_autosaved` analytics event exactly once, at + * the post-signup redirect decision — so the activation metric is a 1:1 + * server-side count rather than a `utm_source=signup-autosave` pageview string a + * reload or share could recount. A no-op when this signup did not auto-save + * (explicit `?return=`, or an absent/tampered last-view cookie), so callers can + * hand it the resolver's `autosavedUrl` unconditionally. + */ +export function emitFirstArticleAutosaved( + deps: { logger: HutchLogger.Typed; now: () => Date }, + params: { autosavedUrl: string | undefined; userId: UserId; visitorId?: string }, +): void { + if (params.autosavedUrl === undefined) return; + const event: FirstArticleAutosavedEvent = { + stream: STREAMS.analytics, + event: ANALYTICS_EVENTS.firstArticleAutosaved, + timestamp: deps.now().toISOString(), + user_id: params.userId, + article_host: articleHostFrom(params.autosavedUrl), + ...(params.visitorId ? { visitor_id: params.visitorId } : {}), + }; + deps.logger.info(event); +} diff --git a/projects/hutch/src/runtime/web/auth/google-auth.page.ts b/projects/hutch/src/runtime/web/auth/google-auth.page.ts index 9a4a08abe..ee9b91ceb 100644 --- a/projects/hutch/src/runtime/web/auth/google-auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/google-auth.page.ts @@ -33,9 +33,11 @@ import { SESSION_COOKIE_MAX_AGE_MS, SESSION_COOKIE_NAME } from "@packages/web-se import { LoginPage } from "./auth.component"; import { initFetchUserCount } from "./fetch-user-count"; import { readClickAttribution } from "@packages/web-analytics"; +import type { AnalyticsEvent } from "@packages/web-analytics"; import { consumePendingSaveId } from "../pending-save"; import { consumeLastViewUrl } from "../last-view"; import { resolvePostSignupRedirect } from "./post-signup-redirect"; +import { emitFirstArticleAutosaved } from "./first-article-autosaved"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; import { signState, verifyState } from "./oauth-state"; @@ -74,6 +76,7 @@ interface GoogleAuthDependencies { logError: (message: string, error?: Error) => void; now: () => Date; conversionLogger: HutchLogger.Typed; + analytics: HutchLogger.Typed; foundingAllocation: FoundingAllocation; } @@ -221,7 +224,12 @@ export const initGoogleAuthRoutes = (deps: GoogleAuthDependencies): Router => { }, ); const lastViewUrl = consumeLastViewUrl({ req, res }); - res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl })); + const redirect = resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl }); + emitFirstArticleAutosaved( + { logger: deps.analytics, now: deps.now }, + { autosavedUrl: redirect.autosavedUrl, userId: created.userId, visitorId: req.visitorId }, + ); + res.redirect(303, redirect.location); return; } @@ -288,7 +296,12 @@ export const initGoogleAuthRoutes = (deps: GoogleAuthDependencies): Router => { }, ); const lastViewUrl = consumeLastViewUrl({ req, res }); - res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl })); + const redirect = resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl }); + emitFirstArticleAutosaved( + { logger: deps.analytics, now: deps.now }, + { autosavedUrl: redirect.autosavedUrl, userId: created.userId, visitorId: req.visitorId }, + ); + res.redirect(303, redirect.location); }); return router; diff --git a/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts b/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts index d0d595399..0ec62c54d 100644 --- a/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts @@ -11,6 +11,7 @@ import { import { GoogleIdSchema } from "@packages/test-fixtures/providers/google-auth"; import type { ExchangeGoogleCode } from "@packages/test-fixtures/providers/google-auth"; import { CheckoutSessionIdSchema } from "@packages/test-fixtures/providers/hosted-checkout"; +import { ANALYTICS_EVENTS } from "@packages/web-analytics"; const TEST_FOUNDING_MEMBER_LIMIT = 3; @@ -271,6 +272,17 @@ describe("Google auth routes", () => { expect(response.status).toBe(303); expect(response.headers.location).toBe(AUTOSAVE_LOCATION); expect(cookiesFrom(response).join(";")).toContain("hutch_lastview=;"); + + const autosaves = harness.analytics.events.filter( + (e) => e.event === ANALYTICS_EVENTS.firstArticleAutosaved, + ); + expect(autosaves).toHaveLength(1); + expect(autosaves[0]).toMatchObject({ + event: ANALYTICS_EVENTS.firstArticleAutosaved, + article_host: "example.com", + user_id: expect.any(String), + visitor_id: expect.any(String), + }); }, 30000); it("lets an explicit return URL win over the autosave", async () => { diff --git a/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts b/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts index b0a1fffa5..b8cb4a9ab 100644 --- a/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts +++ b/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts @@ -7,19 +7,24 @@ describe("resolvePostSignupRedirect", () => { it("honours an explicit returnUrl and never auto-saves, even when a lastViewUrl is present", () => { expect( resolvePostSignupRedirect({ returnUrl: "/oauth/authorize?client_id=test", lastViewUrl: ARTICLE_URL }), - ).toBe("/oauth/authorize?client_id=test"); + ).toEqual({ location: "/oauth/authorize?client_id=test" }); }); it("falls back to /queue when there is neither a returnUrl nor a lastViewUrl", () => { - expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: undefined })).toBe("/queue"); + expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: undefined })).toEqual({ + location: "/queue", + }); }); - it("auto-saves the last-viewed article when there is no explicit returnUrl", () => { - expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: ARTICLE_URL })).toBe(AUTOSAVE); + it("auto-saves the last-viewed article when there is no explicit returnUrl, exposing the saved url", () => { + expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: ARTICLE_URL })).toEqual({ + location: AUTOSAVE, + autosavedUrl: ARTICLE_URL, + }); }); it("carries the utm_source marker and url-encodes the article url", () => { - const location = resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: ARTICLE_URL }); + const { location } = resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: ARTICLE_URL }); const params = new URL(location, "http://localhost").searchParams; expect(params.get("url")).toBe(ARTICLE_URL); expect(params.get("utm_source")).toBe("signup-autosave"); @@ -29,7 +34,9 @@ describe("resolvePostSignupRedirect", () => { ["a non-url", "not a url"], ["a javascript: scheme", "javascript:alert(1)"], ["a loopback address", "http://localhost"], - ])("ignores %s lastViewUrl and falls back to /queue", (_label, tampered) => { - expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: tampered })).toBe("/queue"); + ])("ignores %s lastViewUrl and falls back to /queue without an autosave url", (_label, tampered) => { + expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: tampered })).toEqual({ + location: "/queue", + }); }); }); diff --git a/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts b/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts index 832be0dd1..90fbd0a45 100644 --- a/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts +++ b/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts @@ -3,6 +3,17 @@ import { parseReturnUrl } from "./parse-return-url"; const AUTOSAVE_MARKER: [string, string] = ["utm_source", "signup-autosave"]; +export interface PostSignupRedirect { + /** The 303 target. Always internal — either the parsed `?return=` path or the + * `/queue?url=…` autosave URL. */ + location: string; + /** The validated article URL being auto-saved, present only when this signup + * triggers a first-article autosave. Lets the caller emit the discrete + * `first_article_autosaved` event 1:1 with the trigger without re-deriving the + * decision. `undefined` for every non-autosave landing. */ + autosavedUrl?: string; +} + /** Where a fresh signup lands. An explicit `?return=` always wins (the /save * round-trip already carries and saves the held article in that case, so * auto-saving too would double-save). Otherwise, when the anonymous visitor was @@ -13,12 +24,12 @@ const AUTOSAVE_MARKER: [string, string] = ["utm_source", "signup-autosave"]; export function resolvePostSignupRedirect(params: { returnUrl: string | undefined; lastViewUrl: string | undefined; -}): string { +}): PostSignupRedirect { const fallback = parseReturnUrl({ return: params.returnUrl }); - if (params.returnUrl !== undefined) return fallback; - if (params.lastViewUrl === undefined) return fallback; + if (params.returnUrl !== undefined) return { location: fallback }; + if (params.lastViewUrl === undefined) return { location: fallback }; const validation = validateSaveableUrl(params.lastViewUrl); - if (validation.status === "ERROR") return fallback; + if (validation.status === "ERROR") return { location: fallback }; const qs = new URLSearchParams([["url", validation.url], AUTOSAVE_MARKER]); - return `/queue?${qs.toString()}`; + return { location: `/queue?${qs.toString()}`, autosavedUrl: validation.url }; } diff --git a/src/packages/web-analytics/src/analytics.ts b/src/packages/web-analytics/src/analytics.ts index 0feb848e5..848fac007 100644 --- a/src/packages/web-analytics/src/analytics.ts +++ b/src/packages/web-analytics/src/analytics.ts @@ -255,6 +255,24 @@ export interface ViewSaveIntentEvent { is_authenticated: 0 | 1; } +/** + * Emitted server-side the instant a new signup's first article is auto-saved + * into their otherwise-empty queue, exactly once per trigger. A discrete 1:1 + * activation signal that does not lean on the `utm_source=signup-autosave` + * marker surviving on the post-signup `/queue` pageview — a reload, share, or + * bookmark of that URL would recount the marker, but this event fires only at + * the signup redirect decision. `user_id` joins to the resulting `article_read`; + * `visitor_id` joins to the follow-on `view_save_intent` that persists the save. + */ +export interface FirstArticleAutosavedEvent { + stream: typeof STREAMS.analytics; + event: typeof ANALYTICS_EVENTS.firstArticleAutosaved; + timestamp: string; + user_id: UserId; + article_host: string; + visitor_id?: string; +} + export type AnalyticsEvent = | AnalyticsPageview | AnalyticsClick @@ -264,7 +282,8 @@ export type AnalyticsEvent = | ArticleReadEvent | SummaryToggledEvent | ViewOpenedEvent - | ViewSaveIntentEvent; + | ViewSaveIntentEvent + | FirstArticleAutosavedEvent; function shouldLog(params: { req: Request; path: string; statusCode: number }): boolean { if (params.req.method !== "GET") return false; diff --git a/src/packages/web-analytics/src/events.ts b/src/packages/web-analytics/src/events.ts index 703663f0d..a28eeb984 100644 --- a/src/packages/web-analytics/src/events.ts +++ b/src/packages/web-analytics/src/events.ts @@ -25,6 +25,7 @@ export const ANALYTICS_EVENTS = { summaryToggled: "summary_toggled", viewOpened: "view_opened", viewSaveIntent: "view_save_intent", + firstArticleAutosaved: "first_article_autosaved", } as const; /** diff --git a/src/packages/web-analytics/src/index.ts b/src/packages/web-analytics/src/index.ts index f90795ceb..bcb029fce 100644 --- a/src/packages/web-analytics/src/index.ts +++ b/src/packages/web-analytics/src/index.ts @@ -45,6 +45,7 @@ export { type SummaryToggledEvent, type ViewOpenedEvent, type ViewSaveIntentEvent, + type FirstArticleAutosavedEvent, type DeviceClass, type BrowserFamily, } from "./analytics";