mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge branch 'fix/v1439' into release/v1.43.9
This commit is contained in:
commit
b4618f2ec3
55 files changed
+1547
-270
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,11 @@ vi.mock("@/lib/db", () => ({
|
||||
session: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
user: { findFirst: vi.fn() },
|
||||
user: {
|
||||
findFirst: vi.fn(),
|
||||
// The stored row: the password hash is not on the session user.
|
||||
findUnique: vi.fn(async () => ({ passwordHash: "argon" })),
|
||||
},
|
||||
accountGrant: { create: vi.fn(), findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -50,7 +54,6 @@ const OWNER = {
|
||||
role: "USER",
|
||||
username: "owner",
|
||||
totpConfirmedAt: null,
|
||||
passwordHash: "argon",
|
||||
};
|
||||
|
||||
function invite(): NextRequest {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {},
|
||||
// The cached briefing text is read off its own row, not the session user.
|
||||
prisma: { user: { findUnique: async () => null } },
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
@@ -71,7 +72,6 @@ const SESSION_OK = {
|
||||
glucoseUnit: "mg/dL",
|
||||
onboardingTourCompleted: true,
|
||||
disableCoach: false,
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
dashboardWidgetsJson: null,
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
// The password hash is read off the stored row; none here.
|
||||
user: { findUnique: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
|
||||
@@ -406,6 +406,9 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
expect(entries.WEIGHT.lastEnteredHereAt).toBe(
|
||||
enteredHereAt.toISOString(),
|
||||
);
|
||||
// The reading entered here is in Apple Health too, so it is the type's
|
||||
// newest reading: the row and the verdict read the same instant.
|
||||
expect(entries.WEIGHT.lastSeenAt).toBe(enteredHereAt.toISOString());
|
||||
// A read-only type keeps its own verdict and carries no such field.
|
||||
expect(entries.PULSE.stale).toBe(true);
|
||||
expect(entries.PULSE).not.toHaveProperty("lastEnteredHereAt");
|
||||
|
||||
@@ -35,6 +35,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
// The password hash is read off the stored row; none here.
|
||||
user: { findUnique: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
|
||||
@@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
// The password hash is read off the stored row; none here.
|
||||
user: { findUnique: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -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<IntegrationKey, boolean> = {
|
||||
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,
|
||||
|
||||
@@ -28,6 +28,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
// The password hash is read off the stored row; none here.
|
||||
user: { findUnique: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
session: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
passkey: { count: vi.fn() },
|
||||
},
|
||||
@@ -50,7 +51,15 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function mockSession(user: unknown) {
|
||||
function mockSession(user: {
|
||||
id: string;
|
||||
totpConfirmedAt: Date | null;
|
||||
passwordHash?: string | null;
|
||||
}) {
|
||||
// The password hash is read off the stored row, not the session user.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
passwordHash: user.passwordHash ?? null,
|
||||
} as never);
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
|
||||
user,
|
||||
|
||||
@@ -72,13 +72,17 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
function cookie(
|
||||
user: unknown,
|
||||
user: { id: string; passwordHash?: string | null },
|
||||
stamps: {
|
||||
mfaVerifiedAt?: Date | null;
|
||||
createdAt?: Date;
|
||||
reproofAt?: Date | null;
|
||||
},
|
||||
) {
|
||||
// The password hash is read off the stored row, not the session user.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
passwordHash: user.passwordHash ?? null,
|
||||
} as never);
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
|
||||
user,
|
||||
|
||||
@@ -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<AuthContext> {
|
||||
*/
|
||||
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<RecordAuthContext> {
|
||||
async function resolveSwitchedRecord(
|
||||
auth: AuthContext,
|
||||
carrier: Extract<ActingCarrier, { accountId: string }>,
|
||||
admit: (grant: AccountGrant, owner: User) => string | null,
|
||||
admit: (grant: AccountGrant, owner: SessionUser) => string | null,
|
||||
): Promise<RecordAuthContext> {
|
||||
/** 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<void> {
|
||||
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<void>> {
|
||||
const { freshFactor, proofSource } = options;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 `<user>:<token>` 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) {
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
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<ProofOutcome> {
|
||||
// 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;
|
||||
|
||||
@@ -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<FactorUser | null> {
|
||||
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. */
|
||||
|
||||
@@ -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" | "totpConfirmedAt" | "passwordHash">,
|
||||
user: { id: string; totpConfirmedAt: Date | null },
|
||||
enrolled?: boolean,
|
||||
): Promise<RecentProofMethod[]> {
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -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();
|
||||
|
||||
@@ -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<DailyDigest>;
|
||||
loadDigest?: (
|
||||
user: SessionUser,
|
||||
now: Date,
|
||||
locale: Locale,
|
||||
) => Promise<DailyDigest>;
|
||||
}
|
||||
|
||||
function loadDailyBriefingDigest(
|
||||
user: User,
|
||||
user: SessionUser,
|
||||
now: Date,
|
||||
locale: Locale,
|
||||
): Promise<DailyDigest> {
|
||||
@@ -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";
|
||||
|
||||
@@ -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<DailyDigest> {
|
||||
|
||||
@@ -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<typeof import("@/lib/dashboard/snapshot")>()),
|
||||
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();
|
||||
|
||||
@@ -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<typeof import("@/lib/dashboard/snapshot")>()),
|
||||
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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?: <T>(label: string, builder: () => Promise<T>) => Promise<T>,
|
||||
options: SnapshotReadOptions = {},
|
||||
): Promise<SnapshotReadResult> {
|
||||
// `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
|
||||
|
||||
@@ -17,11 +17,17 @@ import {
|
||||
foldMeanSql,
|
||||
isHourlyMeanTypeSql,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
loadUserSourcePriority,
|
||||
utcBucketEnd,
|
||||
} from "@/lib/rollups/measurement-read";
|
||||
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { resolveLabFields } from "@/lib/labs/serialise";
|
||||
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
|
||||
import {
|
||||
readRollupBuckets,
|
||||
ROLLUP_FOLD_WINDOW_MS,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
|
||||
import type {
|
||||
AggregatedFeatures,
|
||||
@@ -40,11 +46,10 @@ interface AllTimeExtremes {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.11 — full-history min / max / mean per measurement type via ONE
|
||||
* grouped SQL aggregation, with NO row materialisation in JS. Fills the
|
||||
* `allTime*` feature fields; the windowed `summarize()` covers trends + recent
|
||||
* windows, and this covers the long-horizon figures the prompt labels
|
||||
* "allTime".
|
||||
* v1.18.11 — full-history min / max / mean per measurement type, folded in
|
||||
* SQL with NO row materialisation in JS. Fills the `allTime*` feature fields;
|
||||
* the windowed `summarize()` covers trends + recent windows, and this covers
|
||||
* the long-horizon figures the prompt labels "allTime".
|
||||
*
|
||||
* One source per (type, day): each source is folded per day first, each day
|
||||
* keeps its ladder-canonical source, then the days fold into the figures —
|
||||
@@ -52,6 +57,26 @@ interface AllTimeExtremes {
|
||||
* row let a device that samples densely (a watch's pulse) outweigh the
|
||||
* readings the source ladder prefers on the same days.
|
||||
*
|
||||
* Where the days come from. A day is the same per-source fold either way —
|
||||
* count, sum, the extremes of the readings and, for an hourly-mean type, the
|
||||
* day value — and the DAY rollup tier already holds exactly that per (type,
|
||||
* source, day), recomputed on every write. Reading the raw rows instead meant
|
||||
* two passes over every reading of these types the account ever recorded on
|
||||
* every call, the largest read on the database. So the figures fold:
|
||||
*
|
||||
* - the DAY tier, for every whole UTC day from `tierFrom` on, for each type
|
||||
* whose tier rows in that range all carry the reading extremes
|
||||
* (migration 0417; the boot re-fold fills older rows) and account for
|
||||
* the live record day by day (same days, same reading counts);
|
||||
* - the raw rows for the rest: the days before `tierFrom` (older than the
|
||||
* fold window, or the partial day the `floor` falls in), and every day of
|
||||
* a type the tier does not cover yet or no longer matches.
|
||||
*
|
||||
* The split is at a UTC midnight, so no day is folded from both, and the
|
||||
* canonical pick per day is the same on both sides. The tier's pulse day
|
||||
* value follows the stored day-mean method; the raw fold follows this
|
||||
* process's mirror of it, which trails a change by at most a minute.
|
||||
*
|
||||
* Only the four types that expose `allTime*` fields are aggregated (weight,
|
||||
* systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type
|
||||
* with no rows is simply absent.
|
||||
@@ -67,15 +92,116 @@ export async function readAllTimeExtremes(
|
||||
): Promise<Map<MeasurementType, AllTimeExtremes>> {
|
||||
const out = new Map<MeasurementType, AllTimeExtremes>();
|
||||
if (types.length === 0) return out;
|
||||
// The first whole UTC day the tier may answer: after the fold window's
|
||||
// edge (the fold writes nothing older, and the edge day may be partial)
|
||||
// and after the floor's own day (only part of it is inside the floor).
|
||||
const windowEdge = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS);
|
||||
const tierFrom = utcBucketEnd(
|
||||
floor !== null && floor > windowEdge ? floor : windowEdge,
|
||||
"DAY",
|
||||
);
|
||||
const typeList = (list: readonly MeasurementType[]) =>
|
||||
Prisma.join(list.map((t) => Prisma.sql`${t}::measurement_type`));
|
||||
|
||||
const [priorityJson, tierRows] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
// A type is read from the tier only when the tier accounts for the live
|
||||
// record day by day: every day with a live reading has DAY rows whose
|
||||
// counts add up to that day's live count, and every DAY row has its day
|
||||
// of live readings. A bucket that outlived its rows (a hard delete with no
|
||||
// rollup recompute) or a day the tier never got fails the comparison and
|
||||
// the type is read raw. The figures from here are the anchor the Coach
|
||||
// snapshot checks its coarse history against, so they must never repeat
|
||||
// a stale bucket. The live side is a count per day off the partial
|
||||
// `(user_id, type, measured_at)` live index, not a read of the rows.
|
||||
prisma.$queryRaw<Array<{ type: string }>>`
|
||||
WITH live AS (
|
||||
SELECT m."type", date_trunc('day', m."measured_at") AS day,
|
||||
COUNT(*)::bigint AS n
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList(types)})
|
||||
AND m."measured_at" >= ${tierFrom}
|
||||
GROUP BY 1, 2
|
||||
),
|
||||
tier AS (
|
||||
SELECT r."type", r."bucket_start" AT TIME ZONE 'UTC' AS day,
|
||||
SUM(r."count")::bigint AS n,
|
||||
bool_and(
|
||||
r."reading_min_value" IS NOT NULL
|
||||
AND r."reading_max_value" IS NOT NULL
|
||||
) AS complete
|
||||
FROM measurement_rollups r
|
||||
WHERE r."user_id" = ${userId}
|
||||
AND r."granularity" = 'DAY'
|
||||
AND r."type" IN (${typeList(types)})
|
||||
AND r."bucket_start" >= ${tierFrom}
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
SELECT COALESCE(l."type", t."type")::text AS type
|
||||
FROM live l
|
||||
FULL JOIN tier t ON t."type" = l."type" AND t.day = l.day
|
||||
GROUP BY 1
|
||||
HAVING bool_and(COALESCE(l.n = t.n AND t.complete, false))
|
||||
`,
|
||||
]);
|
||||
const tierTypes = new Set(tierRows.map((r) => r.type));
|
||||
const fromTier = types.filter((t) => tierTypes.has(t));
|
||||
const rawOnly = types.filter((t) => !tierTypes.has(t));
|
||||
|
||||
const since =
|
||||
floor === null ? Prisma.empty : Prisma.sql`AND m."measured_at" >= ${floor}`;
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const rank = Prisma.raw(
|
||||
buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'),
|
||||
);
|
||||
const typeList = Prisma.join(
|
||||
types.map((t) => Prisma.sql`${t}::measurement_type`),
|
||||
);
|
||||
const rawArms: Prisma.Sql[] = [];
|
||||
if (rawOnly.length > 0) {
|
||||
rawArms.push(Prisma.sql`
|
||||
SELECT m."type", m."source", m."measured_at", m."value"
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList(rawOnly)})
|
||||
${since}`);
|
||||
}
|
||||
if (fromTier.length > 0) {
|
||||
rawArms.push(Prisma.sql`
|
||||
SELECT m."type", m."source", m."measured_at", m."value"
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList(fromTier)})
|
||||
${since}
|
||||
AND m."measured_at" < ${tierFrom}`);
|
||||
}
|
||||
const rank = (alias: string) =>
|
||||
Prisma.raw(
|
||||
buildSourceRankCase(priorityJson, `${alias}."type"`, `${alias}."source"`),
|
||||
);
|
||||
// The tier's canonical days, in the shape of the raw fold's: the day's
|
||||
// reading-weighted sum and count, its reading extremes, and its day value
|
||||
// with a weight of one (a DAY row's `mean` is the day value for an
|
||||
// hourly-mean type).
|
||||
const tierCanon =
|
||||
fromTier.length === 0
|
||||
? Prisma.empty
|
||||
: Prisma.sql`
|
||||
UNION ALL
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (r."type", r."bucket_start")
|
||||
r."type",
|
||||
r."count" AS cnt,
|
||||
COALESCE(r."sum_value", r."count" * r."mean")::double precision
|
||||
AS total,
|
||||
r."reading_min_value" AS min_value,
|
||||
r."reading_max_value" AS max_value,
|
||||
r."mean" AS wsum,
|
||||
1.0::double precision AS wdays
|
||||
FROM measurement_rollups r
|
||||
WHERE r."user_id" = ${userId}
|
||||
AND r."granularity" = 'DAY'
|
||||
AND r."type" IN (${typeList(fromTier)})
|
||||
AND r."bucket_start" >= ${tierFrom}
|
||||
ORDER BY r."type", r."bucket_start", (${rank("r")}), r."source"::text
|
||||
) tier`;
|
||||
// A day of pulse is the mean of its hours' means and the full history the
|
||||
// mean of its days (`day-mean.ts`). `weighted` carries, per source and day,
|
||||
// the day-weighted sum and the day's weight (one) for those types only;
|
||||
@@ -92,14 +218,10 @@ export async function readAllTimeExtremes(
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ type: string; mean: number; min: number; max: number }>
|
||||
>`
|
||||
WITH hourly_src AS (
|
||||
SELECT m.*
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList})
|
||||
${since}
|
||||
AND ${Prisma.raw(isHourlyMeanTypeSql('m."type"'))}
|
||||
WITH live AS (${Prisma.join(rawArms, " UNION ALL ")}),
|
||||
hourly_src AS (
|
||||
SELECT * FROM live l
|
||||
WHERE ${Prisma.raw(isHourlyMeanTypeSql('l."type"'))}
|
||||
),
|
||||
weighted AS (
|
||||
SELECT
|
||||
@@ -113,34 +235,32 @@ export async function readAllTimeExtremes(
|
||||
),
|
||||
per_source AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
date_trunc('day', m."measured_at") AS day,
|
||||
l."type",
|
||||
l."source",
|
||||
date_trunc('day', l."measured_at") AS day,
|
||||
COUNT(*)::int AS cnt,
|
||||
SUM(m."value")::double precision AS total,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList})
|
||||
${since}
|
||||
GROUP BY m."type", m."source", 3
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, w.wsum, w.wdays
|
||||
FROM per_source p
|
||||
LEFT JOIN weighted w
|
||||
ON w."type" = p."type" AND w."source" = p."source" AND w.day = p.day
|
||||
ORDER BY p."type", p.day, (${rank}), p."source"::text
|
||||
SUM(l."value")::double precision AS total,
|
||||
MIN(l."value")::double precision AS min_value,
|
||||
MAX(l."value")::double precision AS max_value
|
||||
FROM live l
|
||||
GROUP BY l."type", l."source", 3
|
||||
)
|
||||
SELECT
|
||||
c."type"::text AS type,
|
||||
${mean}::double precision AS mean,
|
||||
MIN(c.min_value)::double precision AS min,
|
||||
MAX(c.max_value)::double precision AS max
|
||||
FROM canon c
|
||||
FROM (
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, w.wsum, w.wdays
|
||||
FROM per_source p
|
||||
LEFT JOIN weighted w
|
||||
ON w."type" = p."type" AND w."source" = p."source" AND w.day = p.day
|
||||
ORDER BY p."type", p.day, (${rank("p")}), p."source"::text
|
||||
) raw
|
||||
${tierCanon}
|
||||
) c
|
||||
GROUP BY c."type"
|
||||
`;
|
||||
for (const r of rows) {
|
||||
|
||||
@@ -342,21 +342,27 @@ export function classifyMetricFreshness(
|
||||
return samples.map((sample) => {
|
||||
// A type the app writes back to the phone (#1247): what is entered here
|
||||
// goes to Apple Health and never arrives back, so a person who keeps
|
||||
// their readings here would otherwise watch the type go quiet. The newer
|
||||
// of the two is what says whether the type is still in use.
|
||||
// their readings here would otherwise watch the type go quiet. That
|
||||
// reading is in Apple Health too, so the newer of the two is the type's
|
||||
// newest reading: it is what the row shows and what the verdict reads.
|
||||
const written = enteredHere?.has(sample.type)
|
||||
? { lastEnteredHereAt: enteredHere.get(sample.type) ?? null }
|
||||
: {};
|
||||
if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) {
|
||||
return { ...sample, ...written, stale: false };
|
||||
}
|
||||
const seen = newest(
|
||||
toMillis(sample.lastSeenAt),
|
||||
toMillis(written.lastEnteredHereAt),
|
||||
);
|
||||
return {
|
||||
const resolved = {
|
||||
...sample,
|
||||
...written,
|
||||
lastSeenAt:
|
||||
seen != null ? new Date(seen).toISOString() : sample.lastSeenAt,
|
||||
};
|
||||
if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) {
|
||||
return { ...resolved, stale: false };
|
||||
}
|
||||
return {
|
||||
...resolved,
|
||||
stale: seen != null && nowMs - seen > METRIC_QUIET_AFTER_MS,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GEO_BACKFILL_BATCH_CAP,
|
||||
GEO_BACKFILL_CRON,
|
||||
GEO_BACKFILL_QUEUE,
|
||||
GEO_BACKFILL_RETRY_DAYS,
|
||||
GEO_BACKFILL_WINDOW_DAYS,
|
||||
runGeoBackfill,
|
||||
} from "../geo-backfill";
|
||||
@@ -65,6 +66,21 @@ describe("runGeoBackfill", () => {
|
||||
createdAt: {
|
||||
gt: new Date(now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000),
|
||||
},
|
||||
// A row tried less than the retry wait ago is not picked again.
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ geoAttemptedAt: null },
|
||||
{
|
||||
geoAttemptedAt: {
|
||||
lt: new Date(
|
||||
now.getTime() - GEO_BACKFILL_RETRY_DAYS * 86_400_000,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(args?.take).toBe(GEO_BACKFILL_BATCH_CAP);
|
||||
});
|
||||
@@ -91,7 +107,7 @@ describe("runGeoBackfill", () => {
|
||||
});
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledWith({
|
||||
where: { id: "a1" },
|
||||
data: { location: "Berlin, DE" },
|
||||
data: { location: "Berlin, DE", geoAttemptedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +133,7 @@ describe("runGeoBackfill", () => {
|
||||
location: "München, DE",
|
||||
asn: 3320,
|
||||
carrier: "Deutsche Telekom AG",
|
||||
geoAttemptedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -136,7 +153,11 @@ describe("runGeoBackfill", () => {
|
||||
expect(summary.carrierResolved).toBe(1);
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledWith({
|
||||
where: { id: "a2b" },
|
||||
data: { location: "Bochum, DE", carrier: "Deutsche Telekom AG" },
|
||||
data: {
|
||||
location: "Bochum, DE",
|
||||
carrier: "Deutsche Telekom AG",
|
||||
geoAttemptedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,15 +179,20 @@ describe("runGeoBackfill", () => {
|
||||
});
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledWith({
|
||||
where: { id: "a3" },
|
||||
data: { carrier: "Vodafone GmbH", asn: 3209 },
|
||||
data: {
|
||||
carrier: "Vodafone GmbH",
|
||||
asn: 3209,
|
||||
geoAttemptedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the update when the resolver misses entirely, counts as still-unresolved", async () => {
|
||||
it("stamps only the attempt when the resolver misses entirely, counts as still-unresolved", async () => {
|
||||
const prisma = makePrismaMock([{ id: "a4", ipAddress: "192.0.2.1" }]);
|
||||
vi.mocked(lookupIpGeo).mockResolvedValueOnce(EMPTY_GEO);
|
||||
const now = new Date("2026-05-15T12:00:00Z");
|
||||
|
||||
const summary = await runGeoBackfill(prisma);
|
||||
const summary = await runGeoBackfill(prisma, now);
|
||||
|
||||
expect(summary).toEqual({
|
||||
scanned: 1,
|
||||
@@ -174,7 +200,12 @@ describe("runGeoBackfill", () => {
|
||||
carrierResolved: 0,
|
||||
stillUnresolved: 1,
|
||||
});
|
||||
expect(prisma.auditLog.update).not.toHaveBeenCalled();
|
||||
// The try is recorded, so the next passes leave the row alone until
|
||||
// the retry wait has passed.
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledWith({
|
||||
where: { id: "a4" },
|
||||
data: { geoAttemptedAt: now },
|
||||
});
|
||||
});
|
||||
|
||||
it("handles a mixed batch of hits and misses", async () => {
|
||||
@@ -204,7 +235,7 @@ describe("runGeoBackfill", () => {
|
||||
carrierResolved: 2,
|
||||
stillUnresolved: 1,
|
||||
});
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("treats a Prisma update throw as still-unresolved without aborting the pass", async () => {
|
||||
|
||||
@@ -34,9 +34,16 @@
|
||||
*
|
||||
* Idempotent: a row that the resolver still cannot match (private IP
|
||||
* sneaking through CF egress, freshly-allocated range the GeoLite2
|
||||
* release hasn't picked up yet) stays NULL and the next pass tries
|
||||
* again. The cap is per-call, not per-row, so the helper can be
|
||||
* scheduled at any cadence without doubling up.
|
||||
* release hasn't picked up yet) stays NULL. Every row a pass looks at is
|
||||
* stamped `geoAttemptedAt`, and a row stamped less than
|
||||
* `GEO_BACKFILL_RETRY_DAYS` ago is not picked again: an address no
|
||||
* database will ever place used to be looked up on every hourly pass for
|
||||
* its whole 30-day window, and finding it walked every audit row of those
|
||||
* 30 days. The wait still lets a range a later GeoLite2 release learns
|
||||
* resolve on a later pass. The candidates are read through the partial
|
||||
* index `audit_logs_geo_backfill_idx` (migration 0418), which holds only
|
||||
* the rows still missing something. The cap is per-call, not per-row, so
|
||||
* the helper can be scheduled at any cadence without doubling up.
|
||||
*
|
||||
* No notifications, no event ladder — the maintainer can read the
|
||||
* counts off the returned summary and decide whether to keep
|
||||
@@ -47,6 +54,8 @@ import { lookupIpGeo } from "@/lib/geo";
|
||||
|
||||
export const GEO_BACKFILL_BATCH_CAP = 500;
|
||||
export const GEO_BACKFILL_WINDOW_DAYS = 30;
|
||||
/** How long a row the backfill could not complete waits for its next try. */
|
||||
export const GEO_BACKFILL_RETRY_DAYS = 7;
|
||||
|
||||
/**
|
||||
* v1.4.37 — pg-boss queue name + cron expression for the recurring
|
||||
@@ -74,6 +83,9 @@ export async function runGeoBackfill(
|
||||
const cutoff = new Date(
|
||||
now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
const retryBefore = new Date(
|
||||
now.getTime() - GEO_BACKFILL_RETRY_DAYS * 86_400_000,
|
||||
);
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
@@ -85,6 +97,14 @@ export async function runGeoBackfill(
|
||||
OR: [{ location: null }, { carrier: null }],
|
||||
ipAddress: { not: null },
|
||||
createdAt: { gt: cutoff },
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ geoAttemptedAt: null },
|
||||
{ geoAttemptedAt: { lt: retryBefore } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true, ipAddress: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -109,8 +129,14 @@ export async function runGeoBackfill(
|
||||
// ASN MMDB. The online fallback bounds itself with a request timeout.
|
||||
const { location, asn, carrier } = await lookupIpGeo(ip);
|
||||
|
||||
const data: { location?: string; asn?: number; carrier?: string | null } =
|
||||
{};
|
||||
// Stamped whatever the outcome: a row that resolved leaves the candidate
|
||||
// set anyway, and one that did not waits for its next try.
|
||||
const data: {
|
||||
location?: string;
|
||||
asn?: number;
|
||||
carrier?: string | null;
|
||||
geoAttemptedAt: Date;
|
||||
} = { geoAttemptedAt: now };
|
||||
if (location) {
|
||||
data.location = location;
|
||||
summary.located += 1;
|
||||
@@ -121,10 +147,8 @@ export async function runGeoBackfill(
|
||||
summary.carrierResolved += 1;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
summary.stillUnresolved += 1;
|
||||
continue;
|
||||
}
|
||||
const resolved = data.location !== undefined || data.carrier !== undefined;
|
||||
if (!resolved) summary.stillUnresolved += 1;
|
||||
|
||||
try {
|
||||
await prisma.auditLog.update({
|
||||
@@ -135,7 +159,7 @@ export async function runGeoBackfill(
|
||||
// The row may have been deleted by the retention sweeper between
|
||||
// findMany and update. Treat as unresolved; the next pass will
|
||||
// skip it because the row no longer exists.
|
||||
summary.stillUnresolved += 1;
|
||||
if (resolved) summary.stillUnresolved += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getHeapSpaceStatistics } from "node:v8";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
type HeapFigures,
|
||||
type HeapMonitorDeps,
|
||||
emitHeapLine,
|
||||
heapSpaceFigures,
|
||||
isPrivateDirectory,
|
||||
writeDiagnosticHeapSnapshot,
|
||||
} from "../heap-monitor";
|
||||
@@ -46,7 +48,15 @@ const FIGURE_KEYS: Array<keyof HeapFigures> = [
|
||||
"heap_total_mb",
|
||||
"heap_limit_mb",
|
||||
"heap_used_ratio",
|
||||
"new_space_mb",
|
||||
"old_space_mb",
|
||||
"code_space_mb",
|
||||
"large_object_space_mb",
|
||||
"new_large_object_space_mb",
|
||||
"code_large_object_space_mb",
|
||||
"trusted_space_mb",
|
||||
"read_only_space_mb",
|
||||
"other_spaces_mb",
|
||||
"rss_mb",
|
||||
"external_mb",
|
||||
"array_buffers_mb",
|
||||
@@ -125,6 +135,50 @@ describe("heap monitor sampling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("heap space breakdown", () => {
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
it("reports each named space and sums the rest into other_spaces_mb", () => {
|
||||
const figures = heapSpaceFigures([
|
||||
{ space_name: "old_space", space_used_size: 300 * MB },
|
||||
{ space_name: "large_object_space", space_used_size: 120 * MB },
|
||||
{ space_name: "trusted_space", space_used_size: 40 * MB },
|
||||
{ space_name: "shared_space", space_used_size: 2 * MB },
|
||||
{ space_name: "a_space_v8_adds_later", space_used_size: 3 * MB },
|
||||
]);
|
||||
expect(figures).toEqual({
|
||||
new_space_mb: 0,
|
||||
old_space_mb: 300,
|
||||
code_space_mb: 0,
|
||||
large_object_space_mb: 120,
|
||||
new_large_object_space_mb: 0,
|
||||
code_large_object_space_mb: 0,
|
||||
trusted_space_mb: 40,
|
||||
read_only_space_mb: 0,
|
||||
other_spaces_mb: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("accounts for every space this runtime reports", () => {
|
||||
const spaces = getHeapSpaceStatistics();
|
||||
expect(spaces.length).toBeGreaterThan(0);
|
||||
const figures = heapSpaceFigures(spaces);
|
||||
expect(Object.keys(figures).sort()).toEqual(
|
||||
FIGURE_KEYS.filter(
|
||||
(k) => k.endsWith("space_mb") || k === "other_spaces_mb",
|
||||
)
|
||||
.map(String)
|
||||
.sort(),
|
||||
);
|
||||
const total = spaces.reduce((sum, s) => sum + s.space_used_size, 0);
|
||||
const reported = Object.values(figures).reduce((a, b) => a + b, 0);
|
||||
// Each space is rounded to the megabyte on its own.
|
||||
expect(Math.abs(reported - total / MB)).toBeLessThanOrEqual(
|
||||
Object.keys(figures).length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("heap pressure report", () => {
|
||||
it("fires once at 85 % with the work in flight, at warn", () => {
|
||||
const h = harness();
|
||||
|
||||
@@ -72,12 +72,66 @@ export const HEAP_CRITICAL_RATIO = 0.95;
|
||||
export const HEAP_PRESSURE_COOLDOWN_MS = 30 * 60_000;
|
||||
export const HEAP_PRESSURE_LIST_MAX = 50;
|
||||
|
||||
export interface HeapFigures {
|
||||
/**
|
||||
* The used size of each V8 heap space, by the field it is reported under.
|
||||
*
|
||||
* `old_space` alone left most of a large heap unexplained: a buffer or string
|
||||
* past V8's large-object threshold (a profile picture read into memory, a
|
||||
* long JSON payload) lives in `large_object_space`, compiled code in the code
|
||||
* spaces, and since V8 12 bytecode and other sandboxed objects in
|
||||
* `trusted_space`. Every space a runtime reports that is not named here
|
||||
* (the shared spaces, a space a later V8 adds) is summed into
|
||||
* `other_spaces_mb`, so the fields account for the whole used heap and the
|
||||
* line keeps one shape whatever the Node version.
|
||||
*/
|
||||
const HEAP_SPACE_FIELDS = {
|
||||
new_space: "new_space_mb",
|
||||
old_space: "old_space_mb",
|
||||
code_space: "code_space_mb",
|
||||
large_object_space: "large_object_space_mb",
|
||||
new_large_object_space: "new_large_object_space_mb",
|
||||
code_large_object_space: "code_large_object_space_mb",
|
||||
trusted_space: "trusted_space_mb",
|
||||
read_only_space: "read_only_space_mb",
|
||||
} as const;
|
||||
|
||||
type HeapSpaceField =
|
||||
| (typeof HEAP_SPACE_FIELDS)[keyof typeof HEAP_SPACE_FIELDS]
|
||||
| "other_spaces_mb";
|
||||
|
||||
export type HeapSpaceFigures = Record<HeapSpaceField, number>;
|
||||
|
||||
/** The per-space breakdown of `getHeapSpaceStatistics()`, in megabytes. */
|
||||
export function heapSpaceFigures(
|
||||
spaces: ReadonlyArray<{ space_name: string; space_used_size: number }>,
|
||||
): HeapSpaceFigures {
|
||||
const bytes: Record<HeapSpaceField, number> = {
|
||||
new_space_mb: 0,
|
||||
old_space_mb: 0,
|
||||
code_space_mb: 0,
|
||||
large_object_space_mb: 0,
|
||||
new_large_object_space_mb: 0,
|
||||
code_large_object_space_mb: 0,
|
||||
trusted_space_mb: 0,
|
||||
read_only_space_mb: 0,
|
||||
other_spaces_mb: 0,
|
||||
};
|
||||
for (const space of spaces) {
|
||||
const field =
|
||||
HEAP_SPACE_FIELDS[space.space_name as keyof typeof HEAP_SPACE_FIELDS] ??
|
||||
"other_spaces_mb";
|
||||
bytes[field] += space.space_used_size;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(bytes).map(([field, size]) => [field, megabytes(size)]),
|
||||
) as HeapSpaceFigures;
|
||||
}
|
||||
|
||||
export interface HeapFigures extends HeapSpaceFigures {
|
||||
heap_used_mb: number;
|
||||
heap_total_mb: number;
|
||||
heap_limit_mb: number;
|
||||
heap_used_ratio: number;
|
||||
old_space_mb: number;
|
||||
rss_mb: number;
|
||||
external_mb: number;
|
||||
array_buffers_mb: number;
|
||||
@@ -115,9 +169,6 @@ function defaultReadHeap(): { used: number; limit: number } {
|
||||
function defaultReadFigures(registry: InFlightRegistry): HeapFigures {
|
||||
const memory = process.memoryUsage();
|
||||
const limit = getHeapStatistics().heap_size_limit;
|
||||
const oldSpace = getHeapSpaceStatistics().find(
|
||||
(space) => space.space_name === "old_space",
|
||||
);
|
||||
const counts = registry.counts();
|
||||
return {
|
||||
heap_used_mb: megabytes(memory.heapUsed),
|
||||
@@ -125,7 +176,7 @@ function defaultReadFigures(registry: InFlightRegistry): HeapFigures {
|
||||
heap_limit_mb: megabytes(limit),
|
||||
heap_used_ratio:
|
||||
limit > 0 ? Math.round((memory.heapUsed / limit) * 1000) / 1000 : 0,
|
||||
old_space_mb: megabytes(oldSpace?.space_used_size ?? 0),
|
||||
...heapSpaceFigures(getHeapSpaceStatistics()),
|
||||
rss_mb: megabytes(memory.rss),
|
||||
external_mb: megabytes(memory.external),
|
||||
array_buffers_mb: megabytes(memory.arrayBuffers),
|
||||
|
||||
@@ -293,7 +293,7 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43.7 — present only on a type the app writes back to Apple Health (weight, blood pressure, blood glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both this and `lastSeenAt` are. Show it as the server sends it; do not recompute `stale`.",
|
||||
"v1.43.7 — present only on a type the app writes back to Apple Health (weight, blood pressure, blood glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both are. Since v1.43.9 `lastSeenAt` on such a type is the newer of this and the newest reading from Apple Health, because a reading entered here is in Apple Health too; show both as the server sends them and do not recompute `stale`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -188,6 +188,8 @@ describe("recomputeBucketsForMeasurement", () => {
|
||||
sd: 2.0,
|
||||
slope: -0.1,
|
||||
r2: 0.9,
|
||||
reading_min_value: 80.5,
|
||||
reading_max_value: 84.5,
|
||||
},
|
||||
]);
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
@@ -210,14 +212,14 @@ describe("recomputeBucketsForMeasurement", () => {
|
||||
// instead of raising (or being dropped). Param layout per row:
|
||||
// [userId, type, granularity, bucketStart, source, count, mean,
|
||||
// min, max, sum, sd, slope, r2, sumX, sumXy, sumXx, sumYy,
|
||||
// computedAt].
|
||||
// readingMin, readingMax, computedAt].
|
||||
expect(txExecuteRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
const [sql, ...params] = txExecuteRawUnsafe.mock.calls[0];
|
||||
expect(sql).toContain(
|
||||
"ON CONFLICT (user_id, type, granularity, bucket_start, source)",
|
||||
);
|
||||
expect(sql).toContain("DO UPDATE SET");
|
||||
expect(params).toHaveLength(18);
|
||||
expect(params).toHaveLength(20);
|
||||
expect(params[0]).toBe("user-1");
|
||||
expect(params[1]).toBe("WEIGHT");
|
||||
expect(params[2]).toBe("DAY");
|
||||
@@ -227,6 +229,10 @@ describe("recomputeBucketsForMeasurement", () => {
|
||||
// v1.4.39 — sumValue flows through. Cumulative read paths
|
||||
// (steps, flights, distance, daylight, active-energy) consume it.
|
||||
expect(params[9]).toBe(247.5);
|
||||
// The reading extremes, beside the range-widened min / max.
|
||||
expect(params[7]).toBe(80.0);
|
||||
expect(params[17]).toBe(80.5);
|
||||
expect(params[18]).toBe(84.5);
|
||||
|
||||
// WEEK / MONTH / YEAR — three enqueues against the worker queue.
|
||||
expect(bossSend).toHaveBeenCalledTimes(3);
|
||||
@@ -453,13 +459,13 @@ describe("persistRollupRows — large path (via recomputeUserRollups)", () => {
|
||||
expect(deleteMany.mock.calls[0][0].where.userId).toBe("user-1");
|
||||
expect(deleteMany.mock.calls[0][0].where.granularity).toBe("DAY");
|
||||
|
||||
// 3 chunks of 18 params per row, every one an ON CONFLICT DO UPDATE
|
||||
// 3 chunks of 20 params per row, every one an ON CONFLICT DO UPDATE
|
||||
// upsert — tolerant of a mid-transaction hook insert AND of a
|
||||
// concurrent fold on the same partition (v1.28.33, issue #486).
|
||||
expect(txExecuteRawUnsafe).toHaveBeenCalledTimes(3);
|
||||
expect(txExecuteRawUnsafe.mock.calls[0]).toHaveLength(1 + 500 * 18);
|
||||
expect(txExecuteRawUnsafe.mock.calls[1]).toHaveLength(1 + 500 * 18);
|
||||
expect(txExecuteRawUnsafe.mock.calls[2]).toHaveLength(1 + 200 * 18);
|
||||
expect(txExecuteRawUnsafe.mock.calls[0]).toHaveLength(1 + 500 * 20);
|
||||
expect(txExecuteRawUnsafe.mock.calls[1]).toHaveLength(1 + 500 * 20);
|
||||
expect(txExecuteRawUnsafe.mock.calls[2]).toHaveLength(1 + 200 * 20);
|
||||
for (const call of txExecuteRawUnsafe.mock.calls) {
|
||||
expect(call[0]).toContain("ON CONFLICT");
|
||||
expect(call[0]).toContain("DO UPDATE SET");
|
||||
@@ -575,7 +581,7 @@ describe("persistRollupRows — large path (via recomputeUserRollups)", () => {
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(typeof transaction.mock.calls[0][0]).toBe("function");
|
||||
expect(txExecuteRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
expect(txExecuteRawUnsafe.mock.calls[0]).toHaveLength(1 + 2 * 18);
|
||||
expect(txExecuteRawUnsafe.mock.calls[0]).toHaveLength(1 + 2 * 20);
|
||||
expect(result.rowsUpserted).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +184,10 @@ interface RollupRow {
|
||||
sum_xy: number | null;
|
||||
sum_xx: number | null;
|
||||
sum_yy: number | null;
|
||||
// The extremes of the readings' own `value`, without the per-row range
|
||||
// `min_value` / `max_value` widen to. See `MeasurementRollup.readingMinValue`.
|
||||
reading_min_value: number | null;
|
||||
reading_max_value: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -703,7 +707,9 @@ async function runRollupAggregate(
|
||||
SUM(${REBASED_X})::double precision AS sum_x,
|
||||
SUM(${REBASED_X} * m."value")::double precision AS sum_xy,
|
||||
SUM(${REBASED_X} * ${REBASED_X})::double precision AS sum_xx,
|
||||
SUM(m."value" * m."value")::double precision AS sum_yy
|
||||
SUM(m."value" * m."value")::double precision AS sum_yy,
|
||||
MIN(m."value")::double precision AS reading_min_value,
|
||||
MAX(m."value")::double precision AS reading_max_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = $1
|
||||
AND m."type" IN (${typeList})
|
||||
@@ -753,7 +759,9 @@ async function runRollupAggregate(
|
||||
SUM(${REBASED_X})::double precision AS sum_x,
|
||||
SUM(${REBASED_X} * m."value")::double precision AS sum_xy,
|
||||
SUM(${REBASED_X} * ${REBASED_X})::double precision AS sum_xx,
|
||||
SUM(m."value" * m."value")::double precision AS sum_yy
|
||||
SUM(m."value" * m."value")::double precision AS sum_yy,
|
||||
MIN(m."value")::double precision AS reading_min_value,
|
||||
MAX(m."value")::double precision AS reading_max_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = $1
|
||||
AND m."measured_at" >= $2
|
||||
@@ -1089,20 +1097,24 @@ function buildRollupUpsert(
|
||||
row.sum_xy ?? null,
|
||||
row.sum_xx ?? null,
|
||||
row.sum_yy ?? null,
|
||||
row.reading_min_value ?? null,
|
||||
row.reading_max_value ?? null,
|
||||
computedAt,
|
||||
);
|
||||
tuples.push(
|
||||
`($${base + 1}, $${base + 2}::"measurement_type", $${base + 3}::"measurement_rollup_granularity", ` +
|
||||
`$${base + 4}::timestamptz, $${base + 5}::"measurement_source", $${base + 6}::int, ` +
|
||||
`$${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, ` +
|
||||
`$${base + 13}, $${base + 14}, $${base + 15}, $${base + 16}, $${base + 17}, $${base + 18}::timestamptz)`,
|
||||
`$${base + 13}, $${base + 14}, $${base + 15}, $${base + 16}, $${base + 17}, $${base + 18}, ` +
|
||||
`$${base + 19}, $${base + 20}::timestamptz)`,
|
||||
);
|
||||
}
|
||||
const sql = `
|
||||
INSERT INTO measurement_rollups
|
||||
(user_id, type, granularity, bucket_start, source, count, mean,
|
||||
min_value, max_value, sum_value, sd, slope, r2,
|
||||
sum_x, sum_xy, sum_xx, sum_yy, computed_at)
|
||||
sum_x, sum_xy, sum_xx, sum_yy, reading_min_value, reading_max_value,
|
||||
computed_at)
|
||||
VALUES ${tuples.join(", ")}
|
||||
ON CONFLICT (user_id, type, granularity, bucket_start, source)
|
||||
DO UPDATE SET
|
||||
@@ -1118,6 +1130,8 @@ function buildRollupUpsert(
|
||||
sum_xy = EXCLUDED.sum_xy,
|
||||
sum_xx = EXCLUDED.sum_xx,
|
||||
sum_yy = EXCLUDED.sum_yy,
|
||||
reading_min_value = EXCLUDED.reading_min_value,
|
||||
reading_max_value = EXCLUDED.reading_max_value,
|
||||
computed_at = EXCLUDED.computed_at
|
||||
`;
|
||||
return { sql, params };
|
||||
@@ -1369,7 +1383,9 @@ export async function enqueueBootTimeRollupBackfill(
|
||||
// catches up — mirrors the v1.4.39 sum_value rollout, which the
|
||||
// v1.4.41 note confirms converged via this same boot queue. Bounded
|
||||
// to the fold window so days outside it (un-foldable by design)
|
||||
// never re-flag a user on every boot.
|
||||
// never re-flag a user on every boot. The same arm refills the
|
||||
// reading extremes of migration 0417 (`reading_min_value IS NULL`);
|
||||
// the extremes reader treats a NULL as uncovered until it lands.
|
||||
const users = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT DISTINCT id FROM (
|
||||
SELECT DISTINCT mt."user_id" AS id
|
||||
@@ -1395,7 +1411,7 @@ export async function enqueueBootTimeRollupBackfill(
|
||||
FROM measurement_rollups r
|
||||
WHERE r."granularity" = 'DAY'
|
||||
AND r."bucket_start" >= ${foldWindowStart}
|
||||
AND r."sum_xy" IS NULL
|
||||
AND (r."sum_xy" IS NULL OR r."reading_min_value" IS NULL)
|
||||
) discovered
|
||||
`;
|
||||
|
||||
|
||||
Reference in new issue
Block a user