From 567a564913020e6bd992930bb929a70376f53146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 20:07:25 +0200 Subject: [PATCH] perf(auth): resolve the caller to a narrow user projection The session lookup and the Bearer resolver loaded the whole users row on every authenticated request, including the profile picture bytes, the cached briefing text and every stored integration credential. Both now select SESSION_USER_SELECT, a named allowlist of the small columns the request path reads, and so does the owner load for a switched record. The few routes that need a credential or a large column read it themselves at the moment of use: the factor verifiers load the password hash and TOTP secret, the profile response loads the insurance number, and the snapshot read loads the cached briefing text. A structural guard holds the lookups to the projection and keeps every Bytes and secret-shaped column out of it. --- .../session-user-projection-guard.test.ts | 137 ++++++++++++++++++ .../admin/users/[id]/reset-password/route.ts | 5 +- src/app/api/admin/users/[id]/route.ts | 5 +- .../me/mfa/disable/__tests__/route.test.ts | 9 +- src/app/api/auth/me/mfa/disable/route.ts | 11 +- src/app/api/auth/me/mfa/totp/confirm/route.ts | 9 +- src/app/api/auth/me/route.ts | 12 +- .../passkeys/[id]/__tests__/route.test.ts | 11 +- src/app/api/auth/passkeys/[id]/route.ts | 12 +- .../api/auth/password/__tests__/route.test.ts | 7 +- src/app/api/auth/password/route.ts | 11 +- .../api/record-settings/integrations/route.ts | 64 ++++---- src/lib/api-handler.ts | 16 +- .../enrollment-proof-second-factor.test.ts | 31 +++- src/lib/auth/bearer.ts | 5 +- src/lib/auth/existing-factor-proof.ts | 40 +++-- src/lib/auth/mfa/verify-factor.ts | 22 +++ src/lib/auth/second-factor.ts | 13 +- src/lib/auth/session-user.ts | 126 ++++++++++++++++ src/lib/auth/session.ts | 15 +- src/lib/daily/daily-briefing-push.ts | 15 +- src/lib/daily/load-digest.ts | 4 +- .../snapshot-read-briefing-capability.test.ts | 23 ++- .../snapshot-read-briefing-today.test.ts | 24 ++- .../__tests__/snapshot-read-cache-key.test.ts | 8 +- src/lib/dashboard/snapshot-read.ts | 40 +++-- src/lib/jobs/reaction-line.ts | 6 +- 27 files changed, 543 insertions(+), 138 deletions(-) create mode 100644 src/__tests__/session-user-projection-guard.test.ts create mode 100644 src/lib/auth/session-user.ts diff --git a/src/__tests__/session-user-projection-guard.test.ts b/src/__tests__/session-user-projection-guard.test.ts new file mode 100644 index 000000000..36a64c618 --- /dev/null +++ b/src/__tests__/session-user-projection-guard.test.ts @@ -0,0 +1,137 @@ +/** + * The caller lookups load a projection of `User`, never the whole row. + * + * Every authenticated request resolves its caller through the session lookup + * (`lib/auth/session.ts`), the Bearer resolver (`lib/auth/bearer.ts`) or, for a + * switched record, the owner load in `lib/api-handler.ts`. They used to + * `include: { user: true }` / `findUnique({ where })`, which copied the profile + * picture (megabytes), the cached briefing text and every stored credential + * into memory on each request. They now name `SESSION_USER_SELECT`. + * + * Two halves: + * 1. each of those lookups carries the projection, and no file anywhere loads + * a related user row whole with `include: { user: true }`; + * 2. the projection itself leaves out every `Bytes` column of the model and + * every secret-shaped column, derived from `schema.prisma` so a new + * column of either kind is covered without editing this file. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; +import { SESSION_USER_SELECT } from "@/lib/auth/session-user"; + +const SRC = join(process.cwd(), "src"); + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function stripComments(src: string): string { + return src + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +/** The `{ ... }` argument of every call matching `callee`, braces balanced. */ +function callObjects(src: string, callee: RegExp): string[] { + const code = stripComments(src); + const out: string[] = []; + const re = new RegExp(callee.source + /\s*\(\s*\{/.source, "g"); + for (let m = re.exec(code); m; m = re.exec(code)) { + const open = m.index + m[0].length - 1; + let depth = 0; + let i = open; + for (; i < code.length; i++) { + if (code[i] === "{") depth++; + else if (code[i] === "}" && --depth === 0) break; + } + out.push(code.slice(open, i + 1)); + } + return out; +} + +const USER_LOOKUP = /prisma\.user\.find(?:Unique|First)(?:OrThrow)?/; +const SESSION_LOOKUP = /prisma\.session\.find(?:Unique|First)(?:OrThrow)?/; + +describe("caller lookups load the session projection", () => { + it("the session lookups include the user through SESSION_USER_SELECT", () => { + const calls = callObjects( + read("lib/auth/session.ts"), + SESSION_LOOKUP, + ).filter((body) => /\binclude\s*:/.test(body)); + expect(calls.length).toBeGreaterThanOrEqual(2); + for (const body of calls) { + expect(body).toMatch( + /include\s*:\s*\{\s*user\s*:\s*\{\s*select\s*:\s*SESSION_USER_SELECT\s*\}\s*\}/, + ); + } + }); + + it("the Bearer resolver and the switched-owner load select SESSION_USER_SELECT", () => { + for (const rel of ["lib/auth/bearer.ts", "lib/api-handler.ts"]) { + const calls = callObjects(read(rel), USER_LOOKUP); + expect(calls.length, rel).toBeGreaterThan(0); + for (const body of calls) { + expect(body, rel).toMatch(/\bselect\s*:/); + } + expect( + calls.some((body) => /select\s*:\s*SESSION_USER_SELECT\b/.test(body)), + rel, + ).toBe(true); + } + }); + + it("no file loads a related user row whole", () => { + const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !/\.test\.tsx?$/.test(p)); + expect(files.length).toBeGreaterThan(0); + const offenders = files.filter((rel) => + /\buser\s*:\s*true\b/.test( + stripComments(read(rel)).replace(/select\s*:\s*\{[^{}]*\}/g, ""), + ), + ); + expect(offenders).toEqual([]); + }); +}); + +describe("SESSION_USER_SELECT leaves out bytes and secrets", () => { + const schema = readFileSync( + join(process.cwd(), "prisma", "schema.prisma"), + "utf8", + ); + const model = schema.match(/^model User \{([\s\S]*?)^\}/m)?.[1] ?? ""; + const columns = [ + ...model.matchAll(/^\s+(\w+)\s+([A-Z]\w*)(\??)(?:\s|$)/gm), + ].map(([, name, type]) => ({ name, type })); + const selected = Object.keys(SESSION_USER_SELECT); + + it("reads the User model", () => { + expect(columns.length).toBeGreaterThan(50); + expect(selected.length).toBeGreaterThan(0); + }); + + it("names only columns the model has", () => { + const names = new Set(columns.map((c) => c.name)); + expect(selected.filter((key) => !names.has(key))).toEqual([]); + }); + + it("selects no Bytes column", () => { + const bytes = columns.filter((c) => c.type === "Bytes").map((c) => c.name); + expect(bytes).toContain("avatarBytes"); + expect(selected.filter((key) => bytes.includes(key))).toEqual([]); + }); + + it("selects no credential, secret or large cached text", () => { + const secretShaped = + /Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^insightsCachedText$|^environmentAqHistoryJson$/; + const secrets = columns + .map((c) => c.name) + .filter((name) => secretShaped.test(name)); + expect(secrets.length).toBeGreaterThan(10); + expect(selected.filter((key) => secrets.includes(key))).toEqual([]); + }); +}); diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index ae913dddc..257102aeb 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -60,7 +60,10 @@ export const POST = apiHandler( return apiError("Password required", 422); } - const target = await prisma.user.findUnique({ where: { id } }); + const target = await prisma.user.findUnique({ + where: { id }, + select: { locale: true, username: true }, + }); if (!target) return apiError("User not found", 404); const locale = await resolveServerLocale({ diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 8a954c7dd..f55cb71fc 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -60,7 +60,10 @@ export const PUT = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const target = await prisma.user.findUnique({ where: { id } }); + const target = await prisma.user.findUnique({ + where: { id }, + select: { role: true }, + }); if (!target) return apiError("User not found", 404); // Prevent removing the last admin diff --git a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts index a819d6a08..96134f00b 100644 --- a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts @@ -34,6 +34,7 @@ vi.mock("@/lib/rate-limit", () => ({ vi.mock("@/lib/auth/mfa/verify-factor", () => ({ verifyMfaFactor: vi.fn(), + loadFactorUser: vi.fn(), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); @@ -42,7 +43,7 @@ import { POST } from "../route"; import { requireMfaManagementAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; import { destroyOtherSessions } from "@/lib/auth/session"; -import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; function req() { return new Request("http://localhost/api/auth/me/mfa/disable", { @@ -54,6 +55,12 @@ function req() { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(loadFactorUser).mockResolvedValue({ + id: "u1", + totpSecretEncrypted: "enc", + totpLastStep: null, + totpConfirmedAt: new Date("2026-01-01"), + }); vi.mocked(requireMfaManagementAuth).mockResolvedValue({ transport: "cookie", user: { id: "u1" }, diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 1493d61e4..201f4c61f 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -30,7 +30,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { destroyOtherSessions } from "@/lib/auth/session"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; import { mfaDisableSchema } from "@/lib/validations/mfa"; export const dynamic = "force-dynamic"; @@ -72,11 +72,10 @@ export const POST = apiHandler(async (req: Request) => { ); } - const factor = await verifyMfaFactor( - user, - parsed.data.method, - parsed.data.code, - ); + const factorUser = await loadFactorUser(user.id); + const factor = factorUser + ? await verifyMfaFactor(factorUser, parsed.data.method, parsed.data.code) + : { ok: false, replay: false }; if (!factor.ok) { await auditLog("auth.mfa.failed", { userId: user.id, diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 6f30c6c6d..ec21b4bec 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -37,6 +37,7 @@ import { destroyOtherSessions } from "@/lib/auth/session"; import { decrypt } from "@/lib/crypto"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { verifyTotp } from "@/lib/auth/mfa/totp"; +import { loadFactorUser } from "@/lib/auth/mfa/verify-factor"; import { generateRecoveryCodes, persistRecoveryCodes, @@ -69,7 +70,9 @@ export const POST = apiHandler(async (req: Request) => { if (user.totpConfirmedAt) { throw new HttpError(409, "A second factor is already active"); } - if (!user.totpSecretEncrypted) { + // The pending secret and its replay floor are not on the per-request user. + const pending = await loadFactorUser(user.id); + if (!pending?.totpSecretEncrypted) { throw new HttpError(409, "Start enrollment before confirming"); } @@ -90,11 +93,11 @@ export const POST = apiHandler(async (req: Request) => { // Decrypt is fail-closed — a missing/rotated key throws rather than // confirming against junk. - const secret = decrypt(user.totpSecretEncrypted); + const secret = decrypt(pending.totpSecretEncrypted); const result = verifyTotp( secret, parsed.data.code, - user.totpLastStep === null ? null : Number(user.totpLastStep), + pending.totpLastStep === null ? null : Number(pending.totpLastStep), ); if (!result.valid) { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 41a819538..09cebfdc0 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -162,6 +162,7 @@ export const GET = apiHandler(async () => { onboardingRow, ai, coachReasoning, + identity, ] = await Promise.all([ // The actor's own row is already in hand; a switched session needs the // record's `gender`, which is the column the cycle gate derives from. @@ -235,6 +236,13 @@ export const GET = apiHandler(async () => { recordId, authority: aiAuthority, }), + // The encrypted insurance number is not on the per-request user + // (`SESSION_USER_SELECT`); it is read here, for the one response that + // shows it. + prisma.user.findUnique({ + where: { id: user.id }, + select: { insuranceNumberEncrypted: true }, + }), ]); // The masking step, and the one place the record scoping is narrowed rather @@ -286,9 +294,9 @@ export const GET = apiHandler(async () => { // one row never 500s the whole profile fetch (the field just reads // null and the user re-enters it). let insuranceNumber: string | null = null; - if (user.insuranceNumberEncrypted) { + if (identity?.insuranceNumberEncrypted) { try { - insuranceNumber = decrypt(user.insuranceNumberEncrypted); + insuranceNumber = decrypt(identity.insuranceNumberEncrypted); } catch { insuranceNumber = null; } diff --git a/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts b/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts index c4e4adde5..2d8f7039c 100644 --- a/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts +++ b/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { + user: { findUnique: vi.fn() }, passkey: { findUnique: vi.fn(), update: vi.fn(), @@ -40,7 +41,9 @@ import { DELETE, PATCH } from "../route"; import { requireAuth, requireMfaManagementAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; -const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" }; +const USER = { id: "user-1", username: "u" }; +/** The stored credential row; the session user carries no hash. */ +const STORED = { passwordHash: "argon2id$…" }; const ROW = { id: "pk-1", @@ -75,6 +78,7 @@ const commitElevation = vi.fn().mockResolvedValue(undefined); beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue(STORED as never); vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never); commitElevation.mockClear().mockResolvedValue(undefined); vi.mocked(requireMfaManagementAuth).mockResolvedValue({ @@ -256,9 +260,12 @@ describe("DELETE /api/auth/passkeys/{id} — the removal's gate", () => { name: "Only one", } as never); vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + passwordHash: null, + } as never); vi.mocked(requireMfaManagementAuth).mockResolvedValue({ transport: "cookie", - user: { ...USER, passwordHash: null }, + user: USER, session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, commitElevation, } as never); diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 87cd24b77..38fb8ed38 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -128,10 +128,14 @@ export const DELETE = apiHandler( } // Check: at least 1 auth method must remain - const passkeyCount = await prisma.passkey.count({ - where: { userId: user.id }, - }); - const hasPassword = !!user.passwordHash; + const [passkeyCount, credential] = await Promise.all([ + prisma.passkey.count({ where: { userId: user.id } }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { passwordHash: true }, + }), + ]); + const hasPassword = !!credential?.passwordHash; if (passkeyCount <= 1 && !hasPassword) { return apiError( diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts index 1ebb1bf31..5cee4d7cd 100644 --- a/src/app/api/auth/password/__tests__/route.test.ts +++ b/src/app/api/auth/password/__tests__/route.test.ts @@ -20,7 +20,7 @@ vi.mock("@/lib/api-handler", async () => { vi.mock("@/lib/db", () => ({ prisma: { - user: { update: vi.fn() }, + user: { update: vi.fn(), findUnique: vi.fn() }, }, })); @@ -92,7 +92,6 @@ const SESSION_OK = { username: "tester", email: "t@example.com", role: "USER" as const, - passwordHash: "h", onboardingCompletedAt: new Date(), locale: "en", }, @@ -109,6 +108,10 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // The hash is read off the stored row, not the session user. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + passwordHash: "h", + } as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); // Default: account clears the step-up (no MFA enrolled, or a fresh factor). vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 629473990..db421e863 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -58,11 +58,18 @@ export const POST = apiHandler(async (request: NextRequest) => { } const { currentPassword, newPassword } = parsed.data; - if (!user.passwordHash) { + const credential = await prisma.user.findUnique({ + where: { id: user.id }, + select: { passwordHash: true }, + }); + if (!credential?.passwordHash) { return apiError("No password set for this account", 400); } - const currentValid = await verifyPassword(user.passwordHash, currentPassword); + const currentValid = await verifyPassword( + credential.passwordHash, + currentPassword, + ); if (!currentValid) { await recordReproofFailure( user.id, diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts index ced499e0b..027e26b17 100644 --- a/src/app/api/record-settings/integrations/route.ts +++ b/src/app/api/record-settings/integrations/route.ts @@ -38,38 +38,50 @@ export const GET = apiHandler(async () => { const context = await requireGuardianAuth(); const access = resolveGuardianRecordSettingsAccess(context); if (!access) throw new SharingAccessDeniedError(); - const [statuses, withings, whoop, fitbit, googleHealth] = await Promise.all([ - Promise.all( - MANAGED_INTEGRATIONS.map((integration) => - getIntegrationStatus(access.recordId, integration), + const [statuses, withings, whoop, fitbit, googleHealth, credentials] = + await Promise.all([ + Promise.all( + MANAGED_INTEGRATIONS.map((integration) => + getIntegrationStatus(access.recordId, integration), + ), ), - ), - prisma.withingsConnection.findUnique({ - where: { userId: access.recordId }, - select: { id: true }, - }), - prisma.whoopConnection.findUnique({ - where: { userId: access.recordId }, - select: { whoopUserId: true }, - }), - prisma.fitbitConnection.findUnique({ - where: { userId: access.recordId }, - select: { id: true }, - }), - prisma.googleHealthConnection.findUnique({ - where: { userId: access.recordId }, - select: { id: true }, - }), - ]); + prisma.withingsConnection.findUnique({ + where: { userId: access.recordId }, + select: { id: true }, + }), + prisma.whoopConnection.findUnique({ + where: { userId: access.recordId }, + select: { whoopUserId: true }, + }), + prisma.fitbitConnection.findUnique({ + where: { userId: access.recordId }, + select: { id: true }, + }), + prisma.googleHealthConnection.findUnique({ + where: { userId: access.recordId }, + select: { id: true }, + }), + // Presence only; the credentials themselves are not on the per-request + // user and are not read past this null check. + prisma.user.findUnique({ + where: { id: context.user.id }, + select: { + nightscoutUrlEncrypted: true, + polarAccessTokenEncrypted: true, + ouraAccessTokenEncrypted: true, + stravaAccessTokenEncrypted: true, + }, + }), + ]); const connected: Record = { withings: withings !== null, whoop: whoop?.whoopUserId != null, fitbit: fitbit !== null, "google-health": googleHealth !== null, - nightscout: context.user.nightscoutUrlEncrypted !== null, - polar: context.user.polarAccessTokenEncrypted !== null, - oura: context.user.ouraAccessTokenEncrypted !== null, - strava: context.user.stravaAccessTokenEncrypted !== null, + nightscout: (credentials?.nightscoutUrlEncrypted ?? null) !== null, + polar: (credentials?.polarAccessTokenEncrypted ?? null) !== null, + oura: (credentials?.ouraAccessTokenEncrypted ?? null) !== null, + strava: (credentials?.stravaAccessTokenEncrypted ?? null) !== null, // Not offered on a managed profile (no managed integrations before // v1.44); listed so the map stays total. dawarich: false, diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 5f0f9c9fa..35f907691 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { headers } from "next/headers"; -import type { AccountGrant, User } from "@/generated/prisma/client"; +import type { AccountGrant } from "@/generated/prisma/client"; +import { SESSION_USER_SELECT, type SessionUser } from "./auth/session-user"; import { applyBypassRouteHeaders, bypassRouteRefusal, @@ -472,7 +473,7 @@ export type AuthContext = { */ readonly recordEpoch?: number; }; - user: User; + user: SessionUser; /** * Transport the caller authenticated with, derived from the SAME branch that * sets the wide event's `auth_method`: the cookie-session path resolves @@ -886,7 +887,7 @@ export async function requireActorAuth(): Promise { */ export interface RecordAuthContext extends AuthContext { /** The authenticated caller. Equals `user` when the caller acts as themselves. */ - readonly actor: User; + readonly actor: SessionUser; /** The grant being exercised, or null when the caller acts as themselves. */ readonly grantId: string | null; } @@ -1125,7 +1126,7 @@ export async function requireGuardianAuth(): Promise { async function resolveSwitchedRecord( auth: AuthContext, carrier: Extract, - admit: (grant: AccountGrant, owner: User) => string | null, + admit: (grant: AccountGrant, owner: SessionUser) => string | null, ): Promise { /** Every refusal below is this one error, with the reason kept off the wire. */ const denied = (reason: string): SharingAccessDeniedError => { @@ -1155,6 +1156,7 @@ async function resolveSwitchedRecord( const owner = await prisma.user.findUnique({ where: { id: grant.grantorId }, + select: SESSION_USER_SELECT, }); // Unreachable while the FK cascade holds — a grant naming a deleted account // is deleted with it. Fail closed anyway: an authorization we cannot resolve @@ -1584,7 +1586,7 @@ export async function requireRecentProof(options: { * caller themselves (the admin routes, which must stay on `requireAdmin`). */ export async function assertRecentCookieProof( - user: User, + user: SessionUser, sessionId: string, ): Promise { const enrolled = await hasSecondFactorEnrolled(user); @@ -1713,7 +1715,7 @@ export const STEP_UP_ELEVATION_HEADER = "x-step-up"; * own device login. A comment would not have caught that; a type does. */ export type MfaManagementContext = { - user: User; + user: SessionUser; /** * Spend the elevation. Call it immediately BEFORE the mutation, once every * cheap validation has passed — a 429, a 422, or a wrong factor code must not @@ -1837,7 +1839,7 @@ export async function requireMfaManagementAuth( * the wire. */ async function resolveBearerElevation( - auth: { user: User; apiTokenId: string }, + auth: { user: SessionUser; apiTokenId: string }, options: { freshFactor: boolean; proofSource: FreshFactorProofSource }, ): Promise<() => Promise> { const { freshFactor, proofSource } = options; diff --git a/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts index 0caecb261..a6dac826c 100644 --- a/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts +++ b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { session: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, webauthnMfaCredential: { count: vi.fn() }, passkey: { count: vi.fn() }, authChallenge: { findUnique: vi.fn() }, @@ -38,7 +39,10 @@ vi.mock("@/lib/auth/mfa/webauthn", () => ({ verifyMfaAuthentication: vi.fn(), createMfaAuthenticationOptions: vi.fn(), })); -vi.mock("@/lib/auth/mfa/verify-factor", () => ({ verifyMfaFactor: vi.fn() })); +vi.mock("@/lib/auth/mfa/verify-factor", () => ({ + verifyMfaFactor: vi.fn(), + loadFactorUser: vi.fn(), +})); vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, requireCookieAuth: vi.fn(), @@ -47,7 +51,7 @@ vi.mock("@/lib/api-handler", () => ({ import { prisma } from "@/lib/db"; import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit"; import { verifyPassword } from "@/lib/auth/password"; -import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; import { createRegistrationOptions } from "@/lib/auth/passkey"; import { requireCookieAuth } from "@/lib/api-handler"; import { @@ -79,6 +83,21 @@ const ENROLLED: { }; const PLAIN = { ...ENROLLED, totpSecretEncrypted: null, totpConfirmedAt: null }; +/** + * The account as the request sees it: its secrets live in the stored row, + * which the verifiers read for themselves, and not on the session user. + */ +function stored(user: typeof ENROLLED | typeof PLAIN) { + vi.mocked(prisma.user.findUnique).mockResolvedValue(user as never); + vi.mocked(loadFactorUser).mockResolvedValue(user as never); + return { + id: user.id, + username: user.username, + email: user.email, + totpConfirmedAt: user.totpConfirmedAt, + }; +} + function req(body?: unknown): Request { return new Request("http://localhost/api/auth/me/mfa/totp/setup", { method: "POST", @@ -101,7 +120,7 @@ function session(stamps: { function gate(user: typeof ENROLLED | typeof PLAIN, request: Request) { return checkCookieEnrollmentProof({ - user: user as never, + user: stored(user), sessionId: "sess-1", request, ipAddress: "203.0.113.9", @@ -198,7 +217,7 @@ describe("POST /api/auth/passkey/register-options", () => { it("refuses a password on an account with a second factor, before verifying it", async () => { vi.mocked(requireCookieAuth).mockResolvedValue({ - user: ENROLLED, + user: stored(ENROLLED), session: { id: "sess-1" }, } as never); const res = await PASSKEY_REGISTER_OPTIONS( @@ -214,7 +233,7 @@ describe("POST /api/auth/passkey/register-options", () => { it("still takes a password on an account without one", async () => { vi.mocked(requireCookieAuth).mockResolvedValue({ - user: PLAIN, + user: stored(PLAIN), session: { id: "sess-1" }, } as never); vi.mocked(verifyPassword).mockResolvedValue(true); @@ -236,7 +255,7 @@ describe("changing the email address", () => { opts: { cookie?: boolean; password?: string | null } = {}, ) { return authorizeSensitiveChange({ - user: user as never, + user: stored(user), cookieSessionId: opts.cookie === false ? null : "sess-1", currentPassword: opts.password ?? null, ipAddress: "203.0.113.9", diff --git a/src/lib/auth/bearer.ts b/src/lib/auth/bearer.ts index 8abb7997f..3af3317fe 100644 --- a/src/lib/auth/bearer.ts +++ b/src/lib/auth/bearer.ts @@ -14,9 +14,9 @@ * HTTP path keeps its existing `auth.bearer.*` audit trail), so this layer adds * no I/O beyond the two reads the validation needs. */ -import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { hashToken } from "@/lib/auth/hmac"; +import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user"; /** Why a Bearer token was rejected. Stable handles for audit details. */ export type BearerAuthReason = @@ -67,7 +67,7 @@ export class BearerAuthError extends Error { /** The validated identity behind a Bearer token. */ export interface BearerResolution { - user: User; + user: SessionUser; /** The `ApiToken` row id (the token-id half of the `:` binding). */ tokenId: string; /** The token's granted scopes (`["*"]` = wildcard). */ @@ -147,6 +147,7 @@ export async function resolveBearerToken( const user = await prisma.user.findUnique({ where: { id: apiToken.userId }, + select: SESSION_USER_SELECT, }); if (!user) { diff --git a/src/lib/auth/existing-factor-proof.ts b/src/lib/auth/existing-factor-proof.ts index d4d46ad08..a2e5d5fe5 100644 --- a/src/lib/auth/existing-factor-proof.ts +++ b/src/lib/auth/existing-factor-proof.ts @@ -16,7 +16,6 @@ * (same key, so its counter carries straight over). A shared bucket means a * guesser cannot multiply the budget by rotating between routes. */ -import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { apiError } from "@/lib/api-response"; import { REPROOF_REQUIRED_CODE } from "@/lib/api-errors"; @@ -34,7 +33,7 @@ import { import { verifyPassword } from "@/lib/auth/password"; import { verifyAuthentication } from "@/lib/auth/passkey"; import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn"; -import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; import { stepUpMintSchema, type StepUpMintRequest, @@ -93,6 +92,9 @@ export async function refundReproof(userId: string): Promise { export type ProofOutcome = { ok: true } | { ok: false; reason: string }; +/** The caller a proof is checked for: who they are and whether TOTP is live. */ +type ProofUser = { id: string; totpConfirmedAt: Date | null }; + /** * Verify a proof against the account. Never throws for a bad proof: a * malformed or expired WebAuthn challenge is a failed attempt, not a 500. @@ -102,24 +104,25 @@ export type ProofOutcome = { ok: true } | { ok: false; reason: string }; * A TOTP code is accepted only from a confirmed secret (`verifyMfaFactor`). */ export async function verifyExistingFactorProof( - user: Pick< - User, - | "id" - | "passwordHash" - | "totpSecretEncrypted" - | "totpLastStep" - | "totpConfirmedAt" - >, + user: ProofUser, proof: ExistingFactorProof, ): Promise { + // The secrets are read here, for this one check: the caller's row is the + // per-request projection, which carries none of them. if (proof.method === "password") { - if (!user.passwordHash) return { ok: false, reason: "no_password" }; - const ok = await verifyPassword(user.passwordHash, proof.password); + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { passwordHash: true }, + }); + if (!row?.passwordHash) return { ok: false, reason: "no_password" }; + const ok = await verifyPassword(row.passwordHash, proof.password); return ok ? { ok } : { ok: false, reason: "bad_password" }; } if (proof.method === "totp") { - const result = await verifyMfaFactor(user, "totp", proof.code); + const factorUser = await loadFactorUser(user.id); + if (!factorUser) return { ok: false, reason: "bad_totp" }; + const result = await verifyMfaFactor(factorUser, "totp", proof.code); if (result.ok) return { ok: true }; return { ok: false, reason: result.replay ? "totp_replay" : "bad_totp" }; } @@ -230,7 +233,7 @@ export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000; * throttled and audited through the shared re-proof bucket. */ export async function checkCookieEnrollmentProof(args: { - user: User; + user: ProofUser; sessionId: string; request: Request; ipAddress: string | null; @@ -373,14 +376,7 @@ export type SensitiveChangeProof = * of such an account; the shipped app does not change it. */ export async function authorizeSensitiveChange(args: { - user: Pick< - User, - | "id" - | "passwordHash" - | "totpSecretEncrypted" - | "totpLastStep" - | "totpConfirmedAt" - >; + user: ProofUser; /** The session row id on a cookie request; null on a Bearer request. */ cookieSessionId: string | null; currentPassword: string | null; diff --git a/src/lib/auth/mfa/verify-factor.ts b/src/lib/auth/mfa/verify-factor.ts index 36536af2c..eea2f834e 100644 --- a/src/lib/auth/mfa/verify-factor.ts +++ b/src/lib/auth/mfa/verify-factor.ts @@ -36,6 +36,28 @@ export interface FactorUser { totpConfirmedAt: Date | null; } +/** + * Load the factor columns for one check, at the moment of the check. + * + * The per-request user (`SESSION_USER_SELECT`) carries no secret material, so + * a route that verifies a factor for an already-authenticated caller reads the + * secret and its replay floor here. Reading them fresh is also the right + * answer for the floor: it is the value the guarded update below compares. + */ +export async function loadFactorUser( + userId: string, +): Promise { + return prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + totpSecretEncrypted: true, + totpLastStep: true, + totpConfirmedAt: true, + }, + }); +} + export interface FactorResult { ok: boolean; /** TOTP only: the code matched the secret but its step was already used. */ diff --git a/src/lib/auth/second-factor.ts b/src/lib/auth/second-factor.ts index 78d5ea8e6..b28935755 100644 --- a/src/lib/auth/second-factor.ts +++ b/src/lib/auth/second-factor.ts @@ -1,4 +1,3 @@ -import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; /** @@ -53,19 +52,25 @@ export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey"; * endpoint cannot disagree. */ export async function recentProofMethods( - user: Pick, + user: { id: string; totpConfirmedAt: Date | null }, enrolled?: boolean, ): Promise { const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user)); - const [keys, passkeys] = await Promise.all([ + // Whether a password exists is read here rather than off the caller's row: + // the hash is not part of the per-request user (`SESSION_USER_SELECT`). + const [keys, passkeys, credential] = await Promise.all([ prisma.webauthnMfaCredential.count({ where: { userId: user.id } }), prisma.passkey.count({ where: { userId: user.id } }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { passwordHash: true }, + }), ]); const methods: RecentProofMethod[] = []; if (hasSecond) { if (user.totpConfirmedAt) methods.push("totp"); if (keys > 0) methods.push("webauthn"); - } else if (user.passwordHash) { + } else if (credential?.passwordHash) { methods.push("password"); } if (passkeys > 0) methods.push("passkey"); diff --git a/src/lib/auth/session-user.ts b/src/lib/auth/session-user.ts new file mode 100644 index 000000000..d54cfeccd --- /dev/null +++ b/src/lib/auth/session-user.ts @@ -0,0 +1,126 @@ +import type { Prisma } from "@/generated/prisma/client"; + +/** + * The columns of `User` that ride on every authenticated request. + * + * Every cookie session and every Bearer token resolves its caller through one + * of the lookups in `session.ts` / `bearer.ts`, so whatever this projection + * names is read once per request, for every request the app serves. The row it + * replaced was the whole `users` record, and that record carries a profile + * picture of up to a few megabytes, the cached briefing text, the encrypted + * home location and air-quality history, and every stored integration + * credential. None of that is needed to answer "who is calling", and all of it + * was copied into a fresh buffer on each request. + * + * So the projection is an allowlist: small scalars that the request path reads + * (identity, role, locale and display preferences, the onboarding and MFA + * flags, the small preference documents). Anything else is read by the one + * route that needs it, with its own `select`, at the moment it needs it: + * + * - bytes and large text: `avatarBytes`, `insightsCachedText`, + * `insightsCachedTextEncrypted`, `homeLocationEncrypted`, + * `environmentAqHistoryJson`, `dawarichPrefsEncrypted`; + * - credentials and secrets: `passwordHash`, `totpSecretEncrypted`, + * `totpLastStep`, `telegramBotToken`, every `*Encrypted` integration and + * provider key, `insuranceNumberEncrypted`. + * + * `src/__tests__/session-user-projection-guard.test.ts` holds both halves: no + * caller lookup may load the full row, and none of the columns above may join + * this list. + */ +export const SESSION_USER_SELECT = { + id: true, + username: true, + email: true, + role: true, + oidcIssuer: true, + oidcSub: true, + createdAt: true, + updatedAt: true, + managedProfileAt: true, + heightCm: true, + dateOfBirth: true, + gender: true, + timezone: true, + homeTimezone: true, + homeSince: true, + environmentAirQualityEnabled: true, + codexTokenExpiresAt: true, + codexConnectedAt: true, + codexConnectionStatus: true, + useCentralCodex: true, + insightsPrivacyMode: true, + insightsCachedAt: true, + insightsCachedLocale: true, + insightsSnapshotHash: true, + insightsWarmFailedAt: true, + insightsBriefingRerollDate: true, + morningDigestRefreshedOn: true, + telegramChatId: true, + telegramEnabled: true, + nightscoutAllowPrivateHost: true, + dawarichAllowPrivateHost: true, + ouraScopeDenied: true, + stravaAthleteId: true, + stravaLastActivityAt: true, + stravaBackfillCompletedAt: true, + stravaBackfillResumeBefore: true, + locale: true, + thresholdsJson: true, + dashboardWidgetsJson: true, + insightsLayoutJson: true, + medicationListLayoutJson: true, + documentsLayoutJson: true, + moodTagLayoutJson: true, + glucoseUnit: true, + hasDiabetes: true, + labsLocalOcrEnabled: true, + unitPreference: true, + timeFormat: true, + dateFormat: true, + aiProvider: true, + aiModel: true, + aiBaseUrl: true, + aiCompatBaseUrl: true, + aiCompatModel: true, + aiProviderChain: true, + aiResponseTimeoutSeconds: true, + documentsAutoAiRead: true, + onboardingCompletedAt: true, + onboardingTourCompleted: true, + onboardingTourProgressJson: true, + disclaimerAcknowledgedAt: true, + disclaimerAcknowledgedVersion: true, + healthKitConfigJson: true, + healthKitLastSyncedAt: true, + healthKitLastSyncTrigger: true, + healthKitLastBackgroundSyncAt: true, + displayName: true, + lastReportPracticeName: true, + coachPrefsJson: true, + sourcePriorityJson: true, + lastSyncedAt: true, + syncResetAt: true, + moodReminderEnabled: true, + reportSelectionJson: true, + notificationPrefs: true, + disableCoach: true, + aiUsageLevel: true, + modulePreferencesJson: true, + healthScoreConfigJson: true, + documentQuotaBytes: true, + coachLastSeenAt: true, + avatarContentType: true, + avatarUpdatedAt: true, + totpConfirmedAt: true, + mfaEnforced: true, + passkeyUpgradeNudgeDismissed: true, + fullName: true, + insurerName: true, + insurerIkNumber: true, +} as const satisfies Prisma.UserSelect; + +/** The caller as every authenticated request sees them. */ +export type SessionUser = Prisma.UserGetPayload<{ + select: typeof SESSION_USER_SELECT; +}>; diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 72dc0e0af..06ec6381b 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -1,8 +1,8 @@ import { randomBytes } from "node:crypto"; import { prisma } from "@/lib/db"; import { cookies } from "next/headers"; -import type { User } from "@/generated/prisma/client"; import { hashToken } from "@/lib/auth/hmac"; +import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { getEvent } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; @@ -99,13 +99,13 @@ async function findSessionByCookie(cookieValue: string) { if (cookieValue.startsWith(SESSION_TOKEN_PREFIX)) { return await prisma.session.findUnique({ where: { tokenHash: hashToken(cookieValue) }, - include: { user: true }, + include: { user: { select: SESSION_USER_SELECT } }, }); } const legacy = await prisma.session.findUnique({ where: { id: cookieValue }, - include: { user: true }, + include: { user: { select: SESSION_USER_SELECT } }, }); // A row that already holds a secret has retired its id as a credential. return legacy && legacy.tokenHash === null ? legacy : null; @@ -148,7 +148,10 @@ async function findSessionByCookie(cookieValue: string) { */ export async function validateSessionFromCookieValue( cookieValue: string | undefined | null, -): Promise<{ session: { id: string; expiresAt: Date }; user: User } | null> { +): Promise<{ + session: { id: string; expiresAt: Date }; + user: SessionUser; +} | null> { if (!cookieValue) return null; const session = await findSessionByCookie(cookieValue); if (!session || session.expiresAt < new Date()) return null; @@ -179,7 +182,7 @@ export async function validateSessionWithCreatedAt( cookieValue: string | undefined | null, ): Promise<{ session: { id: string; createdAt: Date; expiresAt: Date }; - user: User; + user: SessionUser; } | null> { if (!cookieValue) return null; const session = await findSessionByCookie(cookieValue); @@ -296,7 +299,7 @@ export async function getSession(): Promise<{ actingAsUserId: string | null; recordEpoch: number; }; - user: User; + user: SessionUser; } | null> { try { await ensureDbCompatibility(); diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index de858255f..1fa56dab9 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -26,7 +26,7 @@ * on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning * window, which is the quiet-hours guarantee for this nudge. */ -import type { User } from "@/generated/prisma/client"; +import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user"; import type { PrismaClient } from "@/generated/prisma/client"; import { annotate, getEvent } from "@/lib/logging/context"; @@ -82,11 +82,15 @@ export type DailyBriefingDispatchResult = export interface DailyBriefingDispatchDeps { dispatch?: typeof dispatchNotification; - loadDigest?: (user: User, now: Date, locale: Locale) => Promise; + loadDigest?: ( + user: SessionUser, + now: Date, + locale: Locale, + ) => Promise; } function loadDailyBriefingDigest( - user: User, + user: SessionUser, now: Date, locale: Locale, ): Promise { @@ -181,7 +185,10 @@ export async function maybeDispatchDailyBriefing( const loadDigest = deps.loadDigest ?? loadDailyBriefingDigest; try { - const user = await prisma.user.findUnique({ where: { id: userId } }); + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: SESSION_USER_SELECT, + }); if (!user) { annotate({ action: { name: "daily.briefing_push.missing_user" } }); return "missing-user"; diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index d08043635..a79699426 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -12,7 +12,7 @@ * module-gated at the build layer) plus the resolved module map the item * builders consult. */ -import type { User } from "@/generated/prisma/client"; +import type { SessionUser } from "@/lib/auth/session-user"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -547,7 +547,7 @@ export interface DailyDigestLoadOptions { } export async function loadDailyDigest( - user: User, + user: SessionUser, now: Date = new Date(), options: DailyDigestLoadOptions = {}, ): Promise { diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts index 5c16fa762..ebe57a1d8 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts @@ -11,7 +11,7 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { User } from "@/generated/prisma/client"; +import type { SessionUser } from "@/lib/auth/session-user"; import { AI_AVAILABLE, aiUnavailable, @@ -29,7 +29,19 @@ const briefingBody = { const buildDashboardSnapshot = vi.fn(async () => briefingBody); const aiCapabilityToServe = vi.fn(); -vi.mock("@/lib/db", () => ({ prisma: {} })); +// Generated a moment ago, so the today rule keeps it and these tests see +// only the capability decision. Read off its own row, not the session user. +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: async () => ({ + insightsCachedText: JSON.stringify({ + briefingGeneratedAt: new Date().toISOString(), + }), + }), + }, + }, +})); vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({ ...(await importOriginal()), buildDashboardSnapshot: () => buildDashboardSnapshot(), @@ -44,16 +56,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ const { readDashboardSnapshotCached } = await import("../snapshot-read"); const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache"); -// Generated a moment ago, so the today rule keeps it and these tests see -// only the capability decision. const USER = { id: "user-briefing-gate", locale: "en", timezone: "UTC", - insightsCachedText: JSON.stringify({ - briefingGeneratedAt: new Date().toISOString(), - }), -} as unknown as User; +} as unknown as SessionUser; beforeEach(() => { __resetAllCachesForTests(); diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts index 9be475763..7cf124e4a 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts @@ -5,7 +5,7 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { User } from "@/generated/prisma/client"; +import type { SessionUser } from "@/lib/auth/session-user"; import { AI_AVAILABLE, aiUnavailable, @@ -47,7 +47,13 @@ const body = (pulseLastSeen: string) => ({ let current = body("2026-10-04T06:50:00.000Z"); const buildDashboardSnapshot = vi.fn(async () => current); -vi.mock("@/lib/db", () => ({ prisma: {} })); +// The cached briefing text is read off its own row, not the session user. +const cacheRow = vi.hoisted(() => ({ + current: null as { insightsCachedText: string | null } | null, +})); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: async () => cacheRow.current } }, +})); vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({ ...(await importOriginal()), buildDashboardSnapshot: () => buildDashboardSnapshot(), @@ -72,16 +78,18 @@ const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache"); /** 2026-10-05 21:00 in Berlin. */ const NOW = new Date("2026-10-05T19:00:00.000Z"); -function user(generatedAt: string | null): User { - return { - id: "user-briefing-today", - locale: "en", - timezone: "Europe/Berlin", +function user(generatedAt: string | null): SessionUser { + cacheRow.current = { insightsCachedText: JSON.stringify({ dailyBriefing: BRIEFING, ...(generatedAt ? { briefingGeneratedAt: generatedAt } : {}), }), - } as unknown as User; + }; + return { + id: "user-briefing-today", + locale: "en", + timezone: "Europe/Berlin", + } as unknown as SessionUser; } beforeEach(() => { diff --git a/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts b/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts index 317455e16..5abc61ed3 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts @@ -14,12 +14,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { User } from "@/generated/prisma/client"; +import type { SessionUser } from "@/lib/auth/session-user"; const buildDashboardSnapshot = vi.fn(async () => ({ tiles: {} })); const resolveServerLocale = vi.fn(async () => "de"); -vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: async () => null } }, +})); vi.mock("@/lib/dashboard/snapshot", () => ({ buildDashboardSnapshot: (...args: unknown[]) => buildDashboardSnapshot(...(args as [])), @@ -46,7 +48,7 @@ const { invalidateUserMedications, } = await import("@/lib/cache/invalidate"); -const USER = { id: "user-key-guard", locale: "de" } as unknown as User; +const USER = { id: "user-key-guard", locale: "de" } as unknown as SessionUser; beforeEach(() => { __resetAllCachesForTests(); diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 23857a904..cd98809e0 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -21,7 +21,7 @@ * that moves the registry back off the global breaks this paragraph, not * just a performance assumption. */ -import type { User } from "@/generated/prisma/client"; +import type { SessionUser } from "@/lib/auth/session-user"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -41,7 +41,10 @@ import { briefingForToday } from "@/lib/daily/briefing-today"; import { requestTodayBriefingWarm } from "@/lib/daily/briefing-today-warm"; import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import { userDayKey } from "@/lib/tz/format"; -import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher"; +import { + INSIGHTS_CACHE_SELECT, + openInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; /** * Per-key TTL for the snapshot cache entry. Strictly greater than the @@ -118,12 +121,29 @@ export interface SnapshotReadOptions { * timing surface; the RSC prefetch passes none. */ export async function readDashboardSnapshotCached( - user: User, + user: SessionUser, time?: (label: string, builder: () => Promise) => Promise, options: SnapshotReadOptions = {}, ): Promise { - // `User` row is already in hand at both call sites — no extra round-trip. - const cachedText = openInsightsCachedText(user); + // The cached briefing text is not on the per-request user + // (`SESSION_USER_SELECT`), so it is read here, beside the locale: every + // read needs it for the generated-at stamp below, not only a cache miss. + const [cacheRow, locale] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: INSIGHTS_CACHE_SELECT, + }), + // v1.21.2 — the briefing recall + forward-look is locale-specific + // prose, so the snapshot cache key carries the resolved locale: an EN and + // a DE session never share a snapshot cell (and never see each other's + // memory wording). The `${user.id}|` prefix still covers the key under + // the measurement-write stale-sweep. + resolveServerLocale({ + userLocale: user.locale, + override: options.locale ?? null, + }), + ]); + const cachedText = openInsightsCachedText(cacheRow); const snapshotUser: SnapshotUserInput = { id: user.id, username: user.username, @@ -143,16 +163,6 @@ export async function readDashboardSnapshotCached( healthScoreConfigJson: user.healthScoreConfigJson, }; - // v1.21.2 — the briefing recall + forward-look is locale-specific - // prose, so the snapshot cache key carries the resolved locale: an EN and - // a DE session never share a snapshot cell (and never see each other's - // memory wording). The `${user.id}|` prefix still covers the key under - // the measurement-write stale-sweep. - const locale = await resolveServerLocale({ - userLocale: user.locale, - override: options.locale ?? null, - }); - // Stale-while-revalidate: a measurement write marks the analytics // bucket stale rather than hard-evicting the snapshot, so a busy iOS // sync serves the prior snapshot immediately (within the bucket's diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index 1051aae19..4ec108073 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -58,6 +58,7 @@ import { import { extractAssessmentSummary } from "@/lib/insights/status-shared"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { SESSION_USER_SELECT } from "@/lib/auth/session-user"; import { singleUserTurn, type CompletionResult } from "@/lib/ai/types"; import type { Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; @@ -500,7 +501,10 @@ export async function runReactionLine( }; } - const user = await prisma.user.findUnique({ where: { id: job.userId } }); + const user = await prisma.user.findUnique({ + where: { id: job.userId }, + select: SESSION_USER_SELECT, + }); if (!user) return { status: "skipped", reason: "no_user" }; const locale = await resolveJobLocale(user.locale);