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);