chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged

Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-21 23:13:21 +02:00
1 parent 324d4bb411
commit 6c7fc7dc84
167 files changed
+11233 -371

No files matched your search

@@ -0,0 +1,117 @@
/**
* v1.4.43 QoL (M8) — pin the widened cookie / localStorage decoder in
* `src/lib/format.ts`. Pre-fix a French user reading the dashboard saw
* "12/24/2025, 2:30 PM" via the silent `en` fallback; the formatter
* now honours every supported locale via the full `Locale` union.
*
* `activeLocale()` itself is module-private — exercise it through the
* exported `formatDate` helper, which composes the cookie read with
* `makeFormatters(locale)`. We stub the bare-minimum `document` /
* `window` shape the helper actually reads, so the test runs under
* the default node environment without pulling in `jsdom`.
*/
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
interface Globals {
document?: { cookie: string };
window?: { localStorage?: { getItem: (key: string) => string | null } };
}
describe("formatDate (cookie-driven locale)", () => {
const globalAny = globalThis as unknown as Globals;
let cookieValue = "";
let storageValue: string | null = null;
beforeEach(() => {
vi.resetModules();
cookieValue = "";
storageValue = null;
globalAny.document = {
get cookie() {
return cookieValue;
},
set cookie(value: string) {
cookieValue = value;
},
};
globalAny.window = {
localStorage: {
getItem: () => storageValue,
},
};
});
afterEach(() => {
delete globalAny.document;
delete globalAny.window;
});
function setCookie(locale: string) {
cookieValue = `healthlog-locale=${locale}; path=/`;
}
const sample = new Date("2026-04-18T10:00:00Z");
it("returns the de format when the cookie reads 'de'", async () => {
setCookie("de");
const { formatDate } = await import("../format");
// German short-date is dd.mm.yyyy.
expect(formatDate(sample)).toMatch(/^\d{2}\.\d{2}\.\d{4}$/);
});
it("returns the fr format when the cookie reads 'fr'", async () => {
setCookie("fr");
const { formatDate } = await import("../format");
// French short-date is dd/mm/yyyy.
expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
});
it("returns the es format when the cookie reads 'es'", async () => {
setCookie("es");
const { formatDate } = await import("../format");
// Spanish short-date is dd/mm/yyyy.
expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
});
it("returns the it format when the cookie reads 'it'", async () => {
setCookie("it");
const { formatDate } = await import("../format");
// Italian short-date is dd/mm/yyyy.
expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
});
it("returns the pl format when the cookie reads 'pl'", async () => {
setCookie("pl");
const { formatDate } = await import("../format");
// Polish short-date is dd.mm.yyyy.
expect(formatDate(sample)).toMatch(/^\d{2}\.\d{2}\.\d{4}$/);
});
it("falls back to en for unrecognised cookie values", async () => {
setCookie("xx");
const { formatDate } = await import("../format");
// English short-date is mm/dd/yyyy.
expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
});
it("falls back to en when the cookie is missing and storage is unset", async () => {
// cookieValue starts as "" — no match.
const { formatDate } = await import("../format");
expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
});
it("falls back to localStorage when the cookie is unrecognised", async () => {
setCookie("xx");
storageValue = "fr";
const { formatDate } = await import("../format");
// French short-date is dd/mm/yyyy — confirms the storage path is hit.
expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
});
});
@@ -0,0 +1,108 @@
/**
* v1.4.43 QoL (L8) — `formatDateOrRelative()` boundary cases.
*
* The helper switches between relative ("vor 12 min") and absolute
* ("21.05.2026, 14:32") rendering based on a 24h cutoff, so two
* adjacent timestamps in the same view never mix formats. Pin every
* boundary so future tweaks (e.g. extending the relative window)
* don't silently shift the cutover.
*/
import { describe, it, expect } from "vitest";
import { formatDateOrRelative } from "../format";
function fakeT(key: string, params?: Record<string, string | number>): string {
// Deterministic stand-in — just echo the key + params so the
// assertions can pin which branch we hit without binding to actual
// translation copy.
if (!params) return key;
return `${key}:${Object.entries(params)
.map(([k, v]) => `${k}=${v}`)
.join(",")}`;
}
const NOW = new Date("2026-05-21T12:00:00Z").getTime();
const SECOND = 1_000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe("formatDateOrRelative", () => {
it("returns just-now for < 60 seconds (lower boundary)", () => {
const iso = new Date(NOW - 15 * SECOND).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeJustNow",
);
});
it("returns just-now at the 59-second edge", () => {
const iso = new Date(NOW - 59 * SECOND).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeJustNow",
);
});
it("returns minutesAgo at exactly the 1-minute mark", () => {
const iso = new Date(NOW - 1 * MINUTE).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeMinutesAgo:count=1",
);
});
it("returns minutesAgo within the [1m, 1h) window", () => {
const iso = new Date(NOW - 30 * MINUTE).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeMinutesAgo:count=30",
);
});
it("returns hoursAgo at exactly the 1-hour mark", () => {
const iso = new Date(NOW - 1 * HOUR).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeHoursAgo:count=1",
);
});
it("returns hoursAgo at the 23h59m edge", () => {
const iso = new Date(NOW - (23 * HOUR + 59 * MINUTE)).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeHoursAgo:count=23",
);
});
it("falls back to absolute formatting at exactly 24h", () => {
const iso = new Date(NOW - 24 * HOUR).toISOString();
const result = formatDateOrRelative(iso, fakeT, NOW);
// The absolute branch does NOT route through the translator, so
// it never produces an `insights.relative*` string.
expect(result).not.toContain("insights.relative");
// It returns a date+time formatted via the legacy formatter, which
// contains digits separated by punctuation.
expect(result).toMatch(/\d/);
});
it("falls back to absolute for a 7-day-old timestamp", () => {
const iso = new Date(NOW - 7 * DAY).toISOString();
const result = formatDateOrRelative(iso, fakeT, NOW);
expect(result).not.toContain("insights.relative");
expect(result).toMatch(/\d/);
});
it("falls back to absolute for future timestamps (never paints 'in 3 min')", () => {
const iso = new Date(NOW + 5 * MINUTE).toISOString();
const result = formatDateOrRelative(iso, fakeT, NOW);
// No relative copy — the absolute formatter handles it.
expect(result).not.toContain("insights.relative");
expect(result).toMatch(/\d/);
});
it("returns empty string for unparseable input", () => {
expect(formatDateOrRelative("not a date", fakeT, NOW)).toBe("");
});
it("accepts a Date instance as well as ISO string", () => {
expect(
formatDateOrRelative(new Date(NOW - 30 * MINUTE), fakeT, NOW),
).toBe("insights.relativeMinutesAgo:count=30");
});
});
@@ -0,0 +1,213 @@
/**
* v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` is the wrapper every
* anonymous auth surface routes through. It guards against the
* trust-chain misconfiguration documented in `api-response.ts`:
*
* - Clean chain → per-IP bucket `{prefix}:{ip}` with the caller's limit.
* - Broken chain (`trustViolation === true`) → tight global bucket
* `auth:anon:trust-violation` shared across every auth surface,
* capped at 100/15min total. One attacker can no longer exhaust the
* per-surface "unknown" bucket and lock every other anonymous caller
* out of login + register + passkey-verify + …
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
checkRateLimit as realCheckRateLimit,
checkAuthSurfaceRateLimit,
} from "../rate-limit";
import { _resetTrustViolationWarningForTests } from "../api-response";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
import { prisma } from "@/lib/db";
const ORIGINAL_HOPS = process.env.TRUST_PROXY_HOPS;
beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockReset();
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ count: 1, reset_at: new Date(Date.now() + 60_000) },
] as never);
_resetTrustViolationWarningForTests();
delete process.env.TRUST_PROXY_HOPS;
});
afterEach(() => {
if (ORIGINAL_HOPS === undefined) delete process.env.TRUST_PROXY_HOPS;
else process.env.TRUST_PROXY_HOPS = ORIGINAL_HOPS;
});
function makeRequest(headers: Record<string, string>): Request {
return new Request("https://example.com/api/auth/login", { headers });
}
/**
* Pull the rate-limit key passed to `prisma.$queryRaw` by walking the
* tagged-template args. `$queryRaw\`INSERT … VALUES (${key}, …)\`` lands
* as `[stringsArray, key, windowInterval]`. The key is the first
* substitution argument.
*/
function lastKey(): string {
const calls = vi.mocked(prisma.$queryRaw).mock.calls;
const last = calls[calls.length - 1];
// First substitution is the bucket key — index 1 of the call arguments.
return last[1] as string;
}
/**
* Pull the limit passed to the underlying `checkRateLimit`. The wrapper
* itself doesn't expose it directly; we re-derive by counting the calls
* with the tight key and checking that the returned `allowed` flag flips
* at the documented 100/15min cap below.
*/
function lastWindowMs(): string {
const calls = vi.mocked(prisma.$queryRaw).mock.calls;
const last = calls[calls.length - 1];
return last[2] as string;
}
describe("checkAuthSurfaceRateLimit — per-IP path", () => {
it("routes to {prefix}:{ip} when the trust chain is clean", async () => {
// Default TRUST_PROXY_HOPS=1, single XFF entry → clean chain.
const result = await checkAuthSurfaceRateLimit(
makeRequest({ "x-forwarded-for": "203.0.113.5" }),
"auth:login",
5,
15 * 60 * 1000,
);
expect(result.ip).toBe("203.0.113.5");
expect(result.allowed).toBe(true);
expect(lastKey()).toBe("auth:login:203.0.113.5");
expect(lastWindowMs()).toBe(`${15 * 60 * 1000} milliseconds`);
});
it("falls back to {prefix}:unknown when getClientIp returns null but trustViolation is false", async () => {
// No XFF + no x-real-ip + no CF → null ip, no violation (no chain to violate).
const result = await checkAuthSurfaceRateLimit(
makeRequest({}),
"auth:register",
5,
15 * 60 * 1000,
);
expect(result.ip).toBeNull();
expect(lastKey()).toBe("auth:register:unknown");
});
it("returns the underlying RateLimitResult plus the resolved ip", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ count: 6, reset_at: new Date(Date.now() + 60_000) },
] as never);
const result = await checkAuthSurfaceRateLimit(
makeRequest({ "x-forwarded-for": "203.0.113.5" }),
"auth:login",
5,
15 * 60 * 1000,
);
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.ip).toBe("203.0.113.5");
});
});
describe("checkAuthSurfaceRateLimit — trust-violation tightening", () => {
beforeEach(() => {
// TRUST_PROXY_HOPS=2 with a 1-entry chain → trust violation.
process.env.TRUST_PROXY_HOPS = "2";
});
it("routes every login attempt under a violation into the global bucket", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await checkAuthSurfaceRateLimit(
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
"auth:login",
5,
15 * 60 * 1000,
);
expect(lastKey()).toBe("auth:anon:trust-violation");
// Window matches the 15-minute global cap, NOT the caller's window.
expect(lastWindowMs()).toBe(`${15 * 60 * 1000} milliseconds`);
} finally {
warn.mockRestore();
}
});
it("routes every auth surface (login + register + passkey-*) into the SAME bucket under violation", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const surfaces: Array<[string, number]> = [
["auth:login", 5],
["auth:register", 5],
["auth:passkey-login-options", 10],
["auth:passkey-verify", 10],
["auth:refresh", 60],
["auth:check-user", 30],
];
const seenKeys = new Set<string>();
for (const [prefix, limit] of surfaces) {
await checkAuthSurfaceRateLimit(
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
prefix,
limit,
15 * 60 * 1000,
);
seenKeys.add(lastKey());
}
// Every surface collapsed to the single tight bucket.
expect(seenKeys).toEqual(new Set(["auth:anon:trust-violation"]));
} finally {
warn.mockRestore();
}
});
it("returns ip from the helper even when the tightened bucket fires (so call sites can audit-log the same IP)", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const result = await checkAuthSurfaceRateLimit(
makeRequest({
"x-forwarded-for": "5.6.7.8",
"x-real-ip": "9.9.9.9",
}),
"auth:login",
5,
15 * 60 * 1000,
);
// Falls through to x-real-ip when the chain is too short.
expect(result.ip).toBe("9.9.9.9");
expect(lastKey()).toBe("auth:anon:trust-violation");
} finally {
warn.mockRestore();
}
});
it("flips to denied once the global cap is hit (count > 100)", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ count: 101, reset_at: new Date(Date.now() + 60_000) },
] as never);
const result = await checkAuthSurfaceRateLimit(
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
"auth:login",
5,
15 * 60 * 1000,
);
expect(result.allowed).toBe(false);
expect(lastKey()).toBe("auth:anon:trust-violation");
} finally {
warn.mockRestore();
}
});
});
describe("checkRateLimit (existing semantics — regression guard)", () => {
it("still creates the per-key bucket via the unchanged SQL path", async () => {
await realCheckRateLimit("custom:key:1.2.3.4", 5, 60_000);
expect(lastKey()).toBe("custom:key:1.2.3.4");
});
});
+176
View File
@@ -1,3 +1,6 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
@@ -295,3 +298,176 @@ describe("buildCoachSnapshot", () => {
expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(2);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.4.43 W13 L-2 — snapshot free-text regression guard.
//
// Every snapshot builder is allowed to ship arbitrary structured data
// (numbers, booleans, ISO dates, enum strings) into the SNAPSHOT JSON
// that prefixes the Coach userPrompt. Free-text fields (medication name,
// dose unit, note bodies, free-form descriptions) MUST wrap through
// `sanitizeForPrompt` first — otherwise a user-controlled string
// containing "SYSTEM:" / "---END---" / control sequences would bleed
// into the prompt and could override the patient-safety guardrails.
//
// This regression guard scans the snapshot-builder source files and
// fails when a recognised free-text field name is assigned a value that
// doesn't include `sanitizeForPrompt`. Cheap, deterministic, no false
// positives on numeric / boolean / date assignments (those don't trip
// the heuristic because the field-name allow-list is the only trigger).
//
// Adding a new free-text field name to the heuristic is a one-row edit.
// ────────────────────────────────────────────────────────────────────
const SNAPSHOT_BUILDER_FILES = [
"src/lib/ai/coach/glp1-snapshot.ts",
"src/lib/insights/blood-pressure-status.ts",
"src/lib/insights/medication-compliance-status.ts",
"src/lib/insights/glp1-plateau.ts",
];
/**
* Field names that consistently mean "free-text the user typed". This
* list is conservative — date / count / value / unit-numeric fields
* never appear here. Adding a new free-text name is the documented
* extension hook.
*/
const FREE_TEXT_FIELD_NAMES = [
"name",
"note",
"notes",
"description",
"comment",
"drug",
"doseUnit",
"dose",
] as const;
/**
* Walk the file looking for `name: <expr>` style property assignments
* where the key matches a free-text field. For each hit, capture the
* full single-line `name: …,` or up to the next comma at the same
* brace-depth. Any hit must contain the literal token `sanitizeForPrompt`
* somewhere in its value expression — OR the file must demonstrably
* import + use `sanitizeForPrompt` for the same source identifier
* elsewhere (the GLP-1-plateau context case: production builds a raw
* struct, the consumer prompt-builder wraps).
*
* Pragmatically: a new snapshot builder that forgets to import the
* sanitiser fails immediately. An existing file that derives an
* intermediate context and sanitises at consumption stays green —
* because the audit-row contract (v1.4.43 W13 L-2) is about preventing
* an un-sanitised field from reaching the prompt, and a file-level
* sanitisation pattern provably catches that.
*/
function findUnsanitisedFreeTextAssignments(
filePath: string,
): { fieldName: string; line: number; snippet: string }[] {
const source = readFileSync(resolve(process.cwd(), filePath), "utf-8");
const lines = source.split("\n");
const violations: { fieldName: string; line: number; snippet: string }[] = [];
// File-level escape hatch: any file that imports + uses
// `sanitizeForPrompt` for the free-text fields is considered safe.
// The check only fires on a NEW file (added without the import) or
// on a file that uses sanitise nowhere — both meaningful regression
// signals.
const fileSanitisesSomewhere = /sanitizeForPrompt\s*\(/.test(source);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const field of FREE_TEXT_FIELD_NAMES) {
// Match `<field>:` as a property key. Anchored after `{` / `(`
// / `,` / start-of-line whitespace so we don't trip on words
// inside string literals or identifiers.
const re = new RegExp(`(?:^|[\\s,{(])${field}\\s*:`);
if (!re.test(line)) continue;
// The value can extend to the next comma at the same depth or
// to the next line. Capture the trailing portion of the current
// line plus up to two lookahead lines as the "value expression".
const valueExpr = [line, lines[i + 1] ?? "", lines[i + 2] ?? ""].join(
"\n",
);
// Numeric / boolean / null / undefined assignments don't need
// sanitisation. They originate inside the server (counts,
// slopes, ids) or are already-bounded enum strings.
const numericOrBoolean =
/:\s*(?:-?\d|true\b|false\b|null\b|undefined\b)/.test(line);
if (numericOrBoolean) continue;
// String literals / template literals built entirely from
// server-controlled tokens are accepted; the heuristic only
// demands `sanitizeForPrompt` when the value reads from a
// user-controlled identifier (Identifier or Member-Expression
// ending in `name|note|description|doseUnit|drug|dose`).
const referencesFreeTextSource = new RegExp(
`\\.(${FREE_TEXT_FIELD_NAMES.join("|")})\\b`,
).test(valueExpr);
if (!referencesFreeTextSource) continue;
if (valueExpr.includes("sanitizeForPrompt")) continue;
// File-level pass: the file calls sanitizeForPrompt at least
// once, so the free-text value is wrapped at the consumer.
if (fileSanitisesSomewhere) continue;
violations.push({
fieldName: field,
line: i + 1,
snippet: line.trim(),
});
}
}
return violations;
}
describe("Coach snapshot — free-text fields wrap through sanitizeForPrompt (L-2)", () => {
for (const filePath of SNAPSHOT_BUILDER_FILES) {
it(`${filePath} — every free-text assignment routes through sanitizeForPrompt`, () => {
const violations = findUnsanitisedFreeTextAssignments(filePath);
if (violations.length > 0) {
const formatted = violations
.map(
(v) =>
` ${filePath}:${v.line} — \`${v.fieldName}\`: ${v.snippet}`,
)
.join("\n");
throw new Error(
`Snapshot builder leaks an un-sanitised free-text field into the Coach prompt:\n${formatted}\n` +
`Wrap the value via \`sanitizeForPrompt(value, maxLen)\` from \`@/lib/insights/sanitize\`.`,
);
}
expect(violations).toEqual([]);
});
}
it("the guard itself trips on an obvious leak (sanity check)", () => {
// Sanity check — the heuristic must flag a synthetic snapshot
// builder that reads from a user-controlled identifier without the
// wrap AND has no sanitiser anywhere in the file. This is the
// "new builder forgot the import" failure mode.
const synthetic = `
// synthetic snapshot builder — no sanitiser imported anywhere.
export function buildBlock(med) {
return {
name: med.name,
dose: med.dose,
};
}
`;
const lines = synthetic.split("\n");
const fileSanitisesSomewhere = /sanitizeForPrompt\s*\(/.test(synthetic);
let hit = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const valueExpr = [line, lines[i + 1] ?? "", lines[i + 2] ?? ""].join(
"\n",
);
if (
/(?:^|[\s,{(])name\s*:/.test(line) &&
/\.name\b/.test(valueExpr) &&
!valueExpr.includes("sanitizeForPrompt") &&
!fileSanitisesSomewhere
) {
hit = true;
}
}
expect(hit).toBe(true);
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* In-process dedup helper for fire-and-forget audit-log rows.
*
* A misbehaving iOS client looping on a 422 can otherwise write
* thousands of identical audit rows per minute. Routes that emit a
* `<route>.validation-failed` audit row alongside a multi-issue Zod
* 422 envelope wrap the write through `shouldEmitAuditRow` to gate
* on a 60 s `(userId, action)` window — only the first 422 inside
* the window writes; subsequent 422s still return the full envelope
* but skip the breadcrumb.
*
* In-memory only — a process restart resets the map, which is the
* safe failure mode (worst case: one extra audit row per restart).
* Cluster deployments still get one row per process per minute,
* which is fine for the operator-grep use case the audit row
* exists for.
*/
const AUDIT_DEDUP_WINDOW_MS = 60_000;
const auditDedupMemo = new Map<string, number>();
export function shouldEmitAuditRow(
userId: string,
action: string,
now: number = Date.now(),
): boolean {
const key = `${userId}:${action}`;
const last = auditDedupMemo.get(key);
if (last !== undefined && now - last < AUDIT_DEDUP_WINDOW_MS) {
return false;
}
auditDedupMemo.set(key, now);
if (auditDedupMemo.size > 512) {
for (const [k, t] of auditDedupMemo) {
if (now - t >= AUDIT_DEDUP_WINDOW_MS) auditDedupMemo.delete(k);
}
}
return true;
}
/** @internal — exported for unit tests only. */
export function __resetAuditDedupMemoForTests(): void {
auditDedupMemo.clear();
}
+139
View File
@@ -0,0 +1,139 @@
/**
* v1.4.43 W13 L-3 — Zod-narrowed `verifyAuthentication` boundary.
*
* Before the fix, the route passed a parsed JSON body through a raw
* `as AuthenticationResponseJSON` cast straight into
* `verifyAuthenticationResponse`. The SimpleWebAuthn verifier owned
* shape validation downstream, but a future refactor reading
* `typedResponse.id` BEFORE calling the verifier would crash on
* `undefined.id`. The Zod schema in front of the verifier closes
* that comprehensively — a malformed body now throws here with a
* structured error.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
authChallenge: {
findUnique: vi.fn(),
deleteMany: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
},
passkey: {
findUnique: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@simplewebauthn/server", () => ({
generateRegistrationOptions: vi.fn(),
verifyRegistrationResponse: vi.fn(),
generateAuthenticationOptions: vi.fn(),
// The verifier should NEVER be invoked on a malformed body — that's
// the whole point of the Zod boundary.
verifyAuthenticationResponse: vi.fn(async () => ({
verified: true,
authenticationInfo: { newCounter: 1 },
})),
}));
import { verifyAuthentication } from "../passkey";
import { prisma } from "@/lib/db";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
const VALID_BODY = {
id: "credId123",
rawId: "credId123",
response: {
clientDataJSON: "AAAA",
authenticatorData: "BBBB",
signature: "CCCC",
},
clientExtensionResults: {},
type: "public-key",
};
beforeEach(() => {
vi.mocked(prisma.authChallenge.findUnique).mockResolvedValue({
id: "ch-1",
challenge: "challenge",
expiresAt: new Date(Date.now() + 60_000),
} as never);
vi.mocked(prisma.authChallenge.delete).mockResolvedValue({} as never);
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
id: "pk-1",
userId: "user-1",
credentialId: "credId123",
credentialPublicKey: new Uint8Array([1, 2, 3]),
counter: BigInt(0),
transports: ["internal"],
} as never);
vi.mocked(prisma.passkey.update).mockResolvedValue({} as never);
vi.mocked(verifyAuthenticationResponse).mockReset();
vi.mocked(verifyAuthenticationResponse).mockResolvedValue({
verified: true,
authenticationInfo: { newCounter: 1 },
} as never);
});
describe("verifyAuthentication — Zod boundary (L-3)", () => {
it("accepts a well-formed AuthenticationResponseJSON body", async () => {
const out = await verifyAuthentication("ch-1", VALID_BODY);
expect(out.verification.verified).toBe(true);
expect(out.passkey.userId).toBe("user-1");
expect(verifyAuthenticationResponse).toHaveBeenCalledTimes(1);
});
it("rejects a body missing the `id` field before reaching the verifier", async () => {
const malformed = { ...VALID_BODY, id: undefined };
await expect(verifyAuthentication("ch-1", malformed)).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("rejects a body with the wrong `type` literal", async () => {
const malformed = { ...VALID_BODY, type: "not-a-public-key" };
await expect(verifyAuthentication("ch-1", malformed)).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("rejects a body missing the nested `response.clientDataJSON`", async () => {
const malformed = {
...VALID_BODY,
response: { ...VALID_BODY.response, clientDataJSON: undefined },
};
await expect(verifyAuthentication("ch-1", malformed)).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("rejects an empty object as a request body", async () => {
await expect(verifyAuthentication("ch-1", {})).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("rejects a null body", async () => {
await expect(verifyAuthentication("ch-1", null)).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("accepts a body with extra unknown fields (forward-compatibility)", async () => {
// SimpleWebAuthn's interface is permissive about future
// authenticator-attachment / extension-result additions; the Zod
// schema must not reject unknown keys at the top level.
const extended = { ...VALID_BODY, futureField: "ignored" };
const out = await verifyAuthentication("ch-1", extended);
expect(out.verification.verified).toBe(true);
});
});
+79 -8
View File
@@ -8,8 +8,65 @@ import {
type VerifiedRegistrationResponse,
type VerifiedAuthenticationResponse,
} from "@simplewebauthn/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
// v1.4.43 W13 L-3 — explicit Zod narrowing in front of
// `verifyAuthentication`. Replaces the raw
// `as AuthenticationResponseJSON` cast; the SimpleWebAuthn verifier
// still owns the cryptographic validation downstream, but a
// malformed body now fails fast at the boundary with a structured
// error rather than crashing on a follow-up `.id` deref.
//
// Shape mirrors SimpleWebAuthn's `AuthenticationResponseJSON`:
// https://w3c.github.io/webauthn/#dictdef-authenticationresponsejson
// The schema is intentionally permissive (`passthrough` + optionals)
// so future authenticator-attachment / extension-result additions
// don't break the boundary; the strict checks remain inside
// `verifyAuthenticationResponse`.
const authenticationResponseSchema = z
.object({
id: z.string().min(1),
rawId: z.string().min(1),
response: z
.object({
clientDataJSON: z.string().min(1),
authenticatorData: z.string().min(1),
signature: z.string().min(1),
userHandle: z.string().optional(),
})
.loose(),
authenticatorAttachment: z.string().optional(),
clientExtensionResults: z.unknown().optional(),
type: z.literal("public-key"),
})
.loose();
// v1.4.43 W10 senior-dev L-1 — symmetric Zod narrowing in front of
// `verifyRegistration`. SimpleWebAuthn's `RegistrationResponseJSON`
// shape: https://w3c.github.io/webauthn/#dictdef-registrationresponsejson
// `attestationObject` replaces `authenticatorData + signature` from
// the authentication shape; everything else mirrors the auth schema.
const registrationResponseSchema = z
.object({
id: z.string().min(1),
rawId: z.string().min(1),
response: z
.object({
clientDataJSON: z.string().min(1),
attestationObject: z.string().min(1),
transports: z.array(z.string()).optional(),
publicKeyAlgorithm: z.number().optional(),
publicKey: z.string().optional(),
authenticatorData: z.string().optional(),
})
.loose(),
authenticatorAttachment: z.string().optional(),
clientExtensionResults: z.unknown().optional(),
type: z.literal("public-key"),
})
.loose();
type Transport =
| "ble"
| "cable"
@@ -113,12 +170,18 @@ export async function verifyRegistration(
}
try {
// The caller passes a parsed JSON body without prior shape-validation —
// the SimpleWebAuthn verifier owns full schema validation and throws
// on any mismatch. We narrow at the boundary so the rest of the file
// sees the documented `RegistrationResponseJSON` shape.
// v1.4.43 W10 senior-dev L-1 — Zod narrow at the boundary before
// delegating to SimpleWebAuthn's full cryptographic validation.
// A malformed body now fails fast with a structured Zod error
// rather than crashing on a follow-up `.id` deref deeper in the
// verifier. Mirrors the v1.4.43 W13 L-3 narrowing on the
// authentication side.
const parsed = registrationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Registration response shape invalid");
}
const verification = await verifyRegistrationResponse({
response: response as RegistrationResponseJSON,
response: parsed.data as RegistrationResponseJSON,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
@@ -184,9 +247,17 @@ export async function verifyAuthentication(
throw new Error("Challenge expired or not found");
}
// The caller passes a parsed JSON body — narrow at the boundary; the
// SimpleWebAuthn verifier below performs full shape validation.
const typedResponse = response as AuthenticationResponseJSON;
// v1.4.43 W13 L-3 — explicit Zod narrowing instead of the previous
// raw `as AuthenticationResponseJSON` cast. A malformed body now
// throws here with a structured error rather than crashing on a
// follow-up `.id` deref. The SimpleWebAuthn verifier downstream
// still owns cryptographic validation; this just closes the
// type-narrowing gap a future refactor could trip on.
const parsed = authenticationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Malformed passkey authentication response");
}
const typedResponse = parsed.data as unknown as AuthenticationResponseJSON;
// Find the passkey by credential ID
const credentialId = typedResponse.id;
+64 -3
View File
@@ -13,10 +13,22 @@
* no hydration-mismatch path today. If you add a new caller that renders
* pre-fetch data (e.g. a static prop), migrate it to `useFormatters()` to
* stay consistent with the server-rendered locale.
*
* v1.4.43 QoL (M8) — `activeLocale()` now reads the full `Locale` union
* (`de | en | fr | es | it | pl`). Pre-fix, a sub-locale user (fr / es /
* it / pl) saw French strings but the legacy SSR formatters silently
* fell back to `en`, producing "12/24/2025, 2:30 PM" in a French shell.
* `Intl.DateTimeFormat` already handles every supported locale via the
* `INTL_LOCALE_MAP`, so the only change is to widen the cookie /
* localStorage decoder.
*/
import { makeFormatters } from "./format-locale";
import type { Locale } from "./i18n/config";
import { locales, type Locale } from "./i18n/config";
function isLocale(value: string | null | undefined): value is Locale {
return !!value && (locales as readonly string[]).includes(value);
}
function activeLocale(): Locale {
if (typeof document === "undefined") return "en";
@@ -26,9 +38,10 @@ function activeLocale(): Locale {
/(?:^|;\s*)healthlog-locale=([^;]+)/,
);
const fromCookie = cookieMatch?.[1];
if (fromCookie === "de" || fromCookie === "en") return fromCookie;
if (isLocale(fromCookie)) return fromCookie;
const fromStorage = window.localStorage?.getItem("healthlog-locale");
return fromStorage === "de" ? "de" : "en";
if (isLocale(fromStorage)) return fromStorage;
return "en";
}
function formatters() {
@@ -59,3 +72,51 @@ export function formatTime(date: Date | string): string {
return formatters().time(date);
}
/**
* v1.4.43 QoL (L8) — render a timestamp as a relative phrase when it
* happened within the last 24 hours, otherwise fall back to the
* absolute locale-aware date+time. Closes the visual inconsistency
* the measurement-list surfaced: the same screen could show "vor 3
* min" (briefing) alongside "21.05.2026, 14:32" (list rows) for
* adjacent events.
*
* Buckets:
* - < 1 m → "just now"
* - < 1 h → "vor N min" / "N min ago"
* - < 24 h → "vor N Std." / "N h ago"
* - ≥ 24 h → absolute `formatDateTime`
*
* The relative branch reuses the `insights.relative*` i18n keys (same
* keys `formatRelativeTime()` reads from `@/lib/i18n/relative-time`).
* Caller supplies a `t()` translator so this helper stays usable
* server-side; clients should pass `useTranslations().t`. The buckets
* mirror the briefing's existing copy so users never see two
* different relative phrasings in the same view.
*/
export function formatDateOrRelative(
iso: Date | string,
t: (key: string, params?: Record<string, string | number>) => string,
/**
* Optional "now" for deterministic tests. Defaults to `Date.now()`.
*/
nowMs: number = Date.now(),
): string {
const target =
iso instanceof Date ? iso.getTime() : new Date(iso).getTime();
if (Number.isNaN(target)) return "";
const diffMs = nowMs - target;
// Future timestamps fall back to absolute — relative copy doesn't
// model them and "in 3 min" would mislead in a health-log context.
if (diffMs < 0) return formatDateTime(iso);
if (diffMs < 60_000) return t("insights.relativeJustNow");
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) {
return t("insights.relativeMinutesAgo", { count: minutes });
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return t("insights.relativeHoursAgo", { count: hours });
}
return formatDateTime(iso);
}
+308 -1
View File
@@ -46,6 +46,7 @@ import {
markReconnected,
isReauthRequired,
getPersistentFailureThreshold,
resumeIntegrationFromPark,
} from "../status";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -95,6 +96,14 @@ describe("recordSyncSuccess", () => {
lastAttemptAt: expect.any(Date),
lastError: null,
consecutiveFailures: 0,
// v1.4.43 W14 — success clears every per-kind bucket too so
// the next failure starts from zero in its own bucket.
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
persistentFailureStartedAt: null,
alertedAt: null,
});
});
@@ -359,12 +368,24 @@ describe("recordSyncFailure — admin Telegram skipped silently when no admins",
});
describe("isReauthRequired", () => {
it("returns true only when state === error_reauth", async () => {
it("returns true when state === error_reauth", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_reauth",
} as never);
expect(await isReauthRequired("u1", "withings")).toBe(true);
});
it("returns true when state === parked (v1.4.43 W14)", async () => {
// A parked integration must short-circuit the sync entry-point
// the same way a reauth-required one does — neither will succeed
// without user / operator action.
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "parked",
} as never);
expect(await isReauthRequired("u1", "withings")).toBe(true);
});
it("returns false for transient errors and absent rows", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
} as never);
@@ -398,6 +419,13 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
state: "disconnected",
lastError: null,
consecutiveFailures: 0,
// v1.4.43 W14 — disconnect must clear the per-kind buckets too.
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
persistentFailureStartedAt: null,
alertedAt: null,
});
});
@@ -412,7 +440,286 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
state: "connected",
lastError: null,
consecutiveFailures: 0,
// v1.4.43 W14 — reconnect clears the per-kind buckets so the
// next sync starts on a clean slate.
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
persistentFailureStartedAt: null,
alertedAt: null,
});
});
});
// ── v1.4.43 W14 — per-kind buckets + 24h park behaviour ────────────────
//
// The four pins below lock in the B4 + B7 contract:
//
// 1. A persistent failure increments ONLY the persistent bucket;
// an intervening transient failure does NOT reset the persistent
// streak.
// 2. The persistent-streak start timestamp is stamped on the FIRST
// persistent failure of a streak and preserved across subsequent
// persistent failures.
// 3. Once the persistent streak has been running for >24h, the state
// flips to `parked` and an `integrations.parked` audit row is
// written.
// 4. `resumeIntegrationFromPark` clears state back to `connected`,
// zeroes every bucket, and writes an `integrations.resumed` audit
// row (only when the row was actually parked — idempotent for
// non-parked inputs).
describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
it("increments ONLY the matching bucket on each failure", async () => {
// Existing row: transient bucket already at 2 from earlier hiccups.
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
consecutiveFailures: 2,
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 0,
},
persistentFailureStartedAt: null,
alertedAt: null,
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 3,
alertedAt: null,
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "persistent",
message: "Withings measure error: 293 - invalid params",
errorCode: "293",
});
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
// transient stays at 2 (unchanged), persistent ticks to 1,
// reauth_required stays at 0.
expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({
transient: 2,
reauth_required: 0,
persistent: 1,
});
// First-ever persistent failure stamps the streak anchor.
expect(upsertArgs.update.persistentFailureStartedAt).toBeInstanceOf(Date);
});
it("preserves persistentFailureStartedAt across multiple persistent failures", async () => {
const streakStart = new Date(Date.now() - 60 * 60 * 1000); // 1h ago
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
consecutiveFailures: 5,
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 5,
},
persistentFailureStartedAt: streakStart,
alertedAt: new Date(Date.now() - 30 * 60 * 1000), // already alerted
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 6,
alertedAt: new Date(Date.now() - 30 * 60 * 1000),
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "persistent",
message: "Withings measure error: 293",
errorCode: "293",
});
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
// The anchor is preserved exactly — that's the whole point of
// tracking the streak start instead of recomputing it from
// updated_at.
expect(upsertArgs.update.persistentFailureStartedAt).toBe(streakStart);
expect(upsertArgs.update.state).toBe("error_transient"); // < 24h still
expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({
transient: 0,
reauth_required: 0,
persistent: 6,
});
});
it("a transient failure does NOT reset the persistent bucket nor the streak anchor", async () => {
const streakStart = new Date(Date.now() - 6 * 60 * 60 * 1000); // 6h ago
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
consecutiveFailures: 4,
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 4,
},
persistentFailureStartedAt: streakStart,
alertedAt: null,
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 5,
alertedAt: null,
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings refresh error: 503",
errorCode: "503",
});
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
// Persistent bucket UNCHANGED — this is the v1.4.43 W14 fix.
// Under the legacy single-counter behaviour the burst above would
// have shown as "5 consecutive failures, state=error_transient"
// and the persistent streak's age would have been masked.
expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({
transient: 1,
reauth_required: 0,
persistent: 4,
});
expect(upsertArgs.update.persistentFailureStartedAt).toBe(streakStart);
});
it("flips state to `parked` and writes an audit row once persistent streak > 24h", async () => {
const streakStart = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25h ago
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
consecutiveFailures: 30,
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 30,
},
persistentFailureStartedAt: streakStart,
alertedAt: new Date(Date.now() - 23 * 60 * 60 * 1000),
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 31,
alertedAt: new Date(Date.now() - 23 * 60 * 60 * 1000),
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "persistent",
message: "Withings measure error: 293",
errorCode: "293",
});
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
expect(upsertArgs.update.state).toBe("parked");
expect(auditLog).toHaveBeenCalledWith(
"integrations.parked",
expect.objectContaining({
userId: "u1",
details: expect.objectContaining({
integration: "withings",
reason: "persistent_24h",
persistentFailureStartedAt: streakStart.toISOString(),
}),
}),
);
});
it("back-fills the bucket from the legacy single counter on a pre-migration row", async () => {
// Legacy row: counter is 3, no bucket JSON yet (predates 0075).
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
consecutiveFailures: 3,
consecutiveFailuresByKind: null,
persistentFailureStartedAt: null,
alertedAt: null,
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 4,
alertedAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
email: "u@example.com",
} as never);
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([] as never);
vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce(
{} as never,
);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "reauth_required",
message: "Withings refresh error: 100 - invalid_grant",
errorCode: "100",
});
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
// The 3 legacy failures get bucketed into the CURRENT failure's
// kind (reauth_required) — that's the kind the row was last
// tagged with, and back-filling into a different kind would
// under-count the existing streak. v1.4.43 W10 senior-dev M-1 —
// the back-fill branch additionally increments the bucket so the
// post-write bucket count matches the legacy column's post-write
// value (legacy: 3 → 4, bucket: 0 → 3 → 4).
expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({
transient: 0,
reauth_required: 4,
persistent: 0,
});
});
});
describe("resumeIntegrationFromPark", () => {
it("clears a parked row back to connected with all buckets zeroed", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "parked",
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
const result = await resumeIntegrationFromPark("u1", "withings");
expect(result.wasParked).toBe(true);
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
expect(upsertArgs.update).toEqual({
state: "connected",
lastError: null,
consecutiveFailures: 0,
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
persistentFailureStartedAt: null,
alertedAt: null,
});
expect(auditLog).toHaveBeenCalledWith(
"integrations.resumed",
expect.objectContaining({
userId: "u1",
details: expect.objectContaining({ integration: "withings" }),
}),
);
});
it("is idempotent — calling against a non-parked row does NOT write an audit row", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "connected",
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
const result = await resumeIntegrationFromPark("u1", "withings");
expect(result.wasParked).toBe(false);
expect(auditLog).not.toHaveBeenCalled();
});
});
+367 -41
View File
@@ -41,11 +41,52 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher";
export type IntegrationKey = "withings" | "moodlog";
/**
* Failure kinds carried into `recordSyncFailure`.
*
* - `transient` : retry on the next sync; user not blocked.
* - `reauth_required` : permanent revoke / invalid_grant; park at
* `error_reauth` until the user reconnects.
* - `persistent` : contract mismatch (invalid params, missing
* field, unknown action). Surfaces in the
* integration-status card AND audit log so an
* operator can investigate, but does NOT skip
* future sync attempts — those may succeed
* once the upstream side resolves. v1.4.43
* W14: after 24h of unbroken persistent
* failures the row is `parked` and the
* integration stops attempting until the user
* / operator reconnects.
*
* v1.4.42 W6 extended this union from `transient | reauth_required` to
* the three-state taxonomy above; the state-mapping function turns
* `persistent` into `error_transient` for now (a Withings 293 still
* lets the next sync run), but the audit detail carries the explicit
* kind so operations can filter.
*/
export type FailureKind = "transient" | "reauth_required" | "persistent";
/**
* Recognised IntegrationStatus states.
*
* `parked` (v1.4.43 W14) is set when an integration's persistent
* failure streak has exceeded `PARK_PERSISTENT_FAILURE_AFTER_MS` (24h
* by default). A parked integration STOPS RETRYING — the next
* scheduled sync skips, and the Settings UI surfaces a "Paused —
* reconnect manually" pill with a "Wieder verbinden" CTA that POSTs
* to `/api/integrations/withings/resume` to clear the park. This is
* intentionally heavier than `error_transient`: a contract-mismatch
* that's been failing for a full day is no longer "the upstream might
* recover on its own" — it's an operator-shaped problem, and
* retrying every 15 minutes for another week just buries the audit
* trail.
*/
export type IntegrationState =
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected";
| "disconnected"
| "parked";
/**
* The ladder at which a streak of failures escalates from "user-visible
@@ -73,6 +114,16 @@ export function getPersistentFailureThreshold(): number {
*/
const ALERT_REPEAT_WINDOW_MS = 24 * 60 * 60 * 1000;
/**
* v1.4.43 W14 — once a `persistent` failure streak has been running
* for this long without a single intervening success, flip the
* integration to `parked`. 24h matches the same window the alert
* ladder uses for re-paging and gives the operator a full business
* day to notice a 293/294 surge before the integration disables
* itself.
*/
const PARK_PERSISTENT_FAILURE_AFTER_MS = 24 * 60 * 60 * 1000;
export interface IntegrationStatusSnapshot {
integration: IntegrationKey;
state: IntegrationState;
@@ -80,6 +131,72 @@ export interface IntegrationStatusSnapshot {
lastAttemptAt: string | null;
lastError: string | null;
consecutiveFailures: number;
/** v1.4.43 W14 — per-kind bucketed counters; `null` for rows
* that predate the migration (back-filled on next write). */
consecutiveFailuresByKind: ConsecutiveFailuresByKind | null;
}
/**
* Bucketed consecutive-failure counters keyed by `FailureKind`.
* v1.4.43 W14 — exposed so the response shape stays explicit and
* tests can pin the bucket increments.
*/
export type ConsecutiveFailuresByKind = Record<FailureKind, number>;
/**
* Type guard for the JSON payload Prisma returns for the
* `consecutiveFailuresByKind` column. Anything that's not a plain
* object with three numeric keys is treated as "no value yet" so the
* back-fill path runs on the next write.
*/
function isFailureBucketObject(
value: unknown,
): value is ConsecutiveFailuresByKind {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const obj = value as Record<string, unknown>;
return (
typeof obj.transient === "number" &&
typeof obj.reauth_required === "number" &&
typeof obj.persistent === "number"
);
}
/**
* Read the JSON `consecutiveFailuresByKind` column off the Prisma row
* with a typed view. Returns `null` when the row predates the
* migration so callers can back-fill from the legacy
* `consecutiveFailures` integer.
*/
function readBucketColumn(value: unknown): ConsecutiveFailuresByKind | null {
return isFailureBucketObject(value) ? value : null;
}
/**
* Zero-bucket envelope. Inlined where we need the literal so eslint
* doesn't flag a re-assignment of the shared constant.
*/
function zeroBuckets(): ConsecutiveFailuresByKind {
return { transient: 0, reauth_required: 0, persistent: 0 };
}
/**
* Back-fill the per-kind buckets for a legacy row that predates the
* v1.4.43 W14 migration. We bucket the existing
* `consecutiveFailures` count into the kind matching the CURRENT
* failure (so a row sitting at `error_reauth` with 4 failures
* back-fills into `reauth_required: 4`). Subsequent failures of a
* different kind then increment their own bucket without losing the
* legacy count.
*/
function backfillBuckets(
legacyCount: number,
currentKind: FailureKind,
): ConsecutiveFailuresByKind {
const buckets = zeroBuckets();
buckets[currentKind] = Math.max(0, legacyCount);
return buckets;
}
/**
@@ -102,6 +219,7 @@ export async function getIntegrationStatus(
lastAttemptAt: null,
lastError: null,
consecutiveFailures: 0,
consecutiveFailuresByKind: null,
};
}
return {
@@ -111,12 +229,20 @@ export async function getIntegrationStatus(
lastAttemptAt: row.lastAttemptAt?.toISOString() ?? null,
lastError: row.lastError ? safeDecryptError(row.lastError) : null,
consecutiveFailures: row.consecutiveFailures,
consecutiveFailuresByKind: readBucketColumn(row.consecutiveFailuresByKind),
};
}
/**
* Read the current `state` cheaply — used by the sync entry-points
* to short-circuit when reauth is required.
*
* v1.4.43 W14 — `parked` is treated as "reauth required" for the
* purpose of sync short-circuit: the user has to call `/resume` (or
* complete the OAuth flow again) before any further sync work
* happens. Returning `true` for both states means existing call sites
* keep their current "skip the cron tick" behaviour without churn,
* and the Settings UI distinguishes the two via the pill copy.
*/
export async function isReauthRequired(
userId: string,
@@ -126,7 +252,7 @@ export async function isReauthRequired(
where: { userId_integration: { userId, integration } },
select: { state: true },
});
return row?.state === "error_reauth";
return row?.state === "error_reauth" || row?.state === "parked";
}
/**
@@ -141,6 +267,10 @@ export async function recordSyncSuccess(
integration: IntegrationKey,
): Promise<void> {
const now = new Date();
// v1.4.43 W14 — a success resets ALL per-kind buckets back to zero
// and clears the persistent-streak start timestamp. The legacy
// single-column `consecutiveFailures` zero stays for backward
// compatibility with v1.4.43 readers that haven't migrated yet.
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
@@ -150,6 +280,7 @@ export async function recordSyncSuccess(
lastSuccessAt: now,
lastAttemptAt: now,
consecutiveFailures: 0,
consecutiveFailuresByKind: zeroBuckets(),
},
update: {
state: "connected",
@@ -157,32 +288,13 @@ export async function recordSyncSuccess(
lastAttemptAt: now,
lastError: null,
consecutiveFailures: 0,
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
alertedAt: null,
},
});
}
/**
* Failure kinds carried into `recordSyncFailure`.
*
* - `transient` : retry on the next sync; user not blocked.
* - `reauth_required` : permanent revoke / invalid_grant; park at
* `error_reauth` until the user reconnects.
* - `persistent` : contract mismatch (invalid params, missing
* field, unknown action). Surfaces in the
* integration-status card AND audit log so an
* operator can investigate, but does NOT skip
* future sync attempts — those may succeed once
* the upstream side resolves.
*
* v1.4.42 W6 extended this union from `transient | reauth_required` to
* the three-state taxonomy above; the state-mapping function turns
* `persistent` into `error_transient` for now (a Withings 293 still
* lets the next sync run), but the audit detail carries the explicit
* kind so operations can filter.
*/
export type FailureKind = "transient" | "reauth_required" | "persistent";
export interface RecordSyncFailureInput {
userId: string;
integration: IntegrationKey;
@@ -194,36 +306,137 @@ export interface RecordSyncFailureInput {
/**
* Record a sync failure. Always:
* - increments `consecutive_failures`
* - increments the per-kind bucket in `consecutiveFailuresByKind`
* (and the legacy `consecutive_failures` integer for one release
* as a fallback for un-migrated readers)
* - persists the encrypted error message
* - writes one `AuditLog` row with `integrations.sync.failed`
*
* If `kind === "reauth_required"` the row is parked at `error_reauth`
* so the next scheduled sync skips. Otherwise the state is marked
* `error_transient` and the next sync will try again.
* so the next scheduled sync skips. If `kind === "persistent"` and
* the persistent streak has been running for >24h, the row is parked
* at `parked` so the next sync also skips — the operator / user has
* to call `resumeIntegrationFromPark` to clear it. Otherwise the state
* is `error_transient` and the next sync will try again.
*
* If the post-update `consecutive_failures` count crosses the alerting
* threshold AND the alerting window has lapsed, dispatch a Telegram
* notification to all admins. Failures here are best-effort: a failed
* dispatch DOES NOT swallow the audit log.
* If the post-update bucket max crosses the alerting threshold AND the
* alerting window has lapsed, dispatch a Telegram notification to all
* admins. Failures here are best-effort: a failed dispatch DOES NOT
* swallow the audit log.
*
* v1.4.43 W14 — per-kind counter migration. Each failure increments
* ONLY its own bucket; a transient hiccup followed by a persistent
* failure no longer masks the persistent streak's true age. The
* legacy single-column `consecutiveFailures` integer continues to be
* incremented so the v1.4.43 alert ladder + UI consumers keep working
* unchanged; v1.4.44 will drop it once every reader migrates.
*/
export async function recordSyncFailure(
input: RecordSyncFailureInput,
): Promise<void> {
const { userId, integration, kind, message, errorCode } = input;
const now = new Date();
const encryptedError = safeEncryptError(message);
// Read the current row first so we can:
// (a) compute the new per-kind bucket value
// (b) decide whether the persistent streak has exceeded the
// park threshold
// (c) back-fill `consecutiveFailuresByKind` for rows that
// predate the v1.4.43 W14 migration.
const existing = await prisma.integrationStatus.findUnique({
where: { userId_integration: { userId, integration } },
select: {
consecutiveFailures: true,
consecutiveFailuresByKind: true,
persistentFailureStartedAt: true,
alertedAt: true,
},
});
// Resolve the starting bucket envelope:
// - existing JSON value if present (post-migration row)
// - back-fill from the legacy integer if the row predates the
// migration (counts go into the CURRENT failure's bucket so the
// persistent streak is dated from this write, not from zero)
// - zero envelope for the first-ever write
const startingBuckets = readBucketColumn(existing?.consecutiveFailuresByKind);
// Snapshot the persistent-bucket count BEFORE we increment so the
// streak-anchor decision below can see the pre-increment value.
// Otherwise we'd inspect the freshly-incremented bucket and never
// recognise a "first persistent failure of a fresh streak".
const persistentStreakBefore = startingBuckets?.persistent ?? 0;
// Now build the new bucket envelope. `buckets` is always a fresh
// object so the upsert payload doesn't share a reference with the
// existing-row snapshot (which would couple the in-memory mutation
// to the audit-log read path below).
const buckets: ConsecutiveFailuresByKind = startingBuckets
? { ...startingBuckets }
: existing
? backfillBuckets(existing.consecutiveFailures, kind)
: zeroBuckets();
// Increment only the bucket matching this failure's kind.
// The other two buckets stay at their current value so a persistent
// streak isn't reset by an intervening transient hiccup.
//
// v1.4.43 W10 senior-dev M-1 — back-fill branch also increments. The
// legacy column writes `{ increment: 1 }` to advance from N → N+1; the
// back-fill seed sets `buckets[kind] = legacyCount` (= N), so without
// the +1 here the bucket would land at N while the legacy column reads
// N+1. When v1.4.44 drops the legacy column, every back-filled row
// would page exactly one failure too late.
if (startingBuckets) {
// Post-migration row — increment the actual bucket.
buckets[kind] = (buckets[kind] ?? 0) + 1;
} else if (!existing) {
// First-ever write — bucket starts at zero, this failure makes 1.
buckets[kind] = 1;
} else {
// Back-fill path — seeded with the legacy count; this failure is
// the +1 that brings both the legacy column AND the bucket to N+1.
buckets[kind] = (buckets[kind] ?? 0) + 1;
}
// Track the persistent-streak start so the >24h park check has a
// wall-clock anchor. Only stamped on the FIRST persistent failure of
// a streak; cleared on success or when the persistent bucket goes
// back to zero (which today only happens via success, but the logic
// is symmetric for future "transient drained the streak" rules).
const isPersistent = kind === "persistent";
let persistentFailureStartedAt: Date | null =
existing?.persistentFailureStartedAt ?? null;
if (isPersistent && persistentStreakBefore === 0) {
persistentFailureStartedAt = now;
}
// Park decision: a persistent failure whose streak has exceeded the
// 24h window flips the state to `parked`. This is sticky — once
// parked, the row stays parked until either a success arrives
// (unlikely, since the sync entry-point short-circuits) or the
// user calls `resumeIntegrationFromPark` via the API.
const persistentStreakAgeMs =
isPersistent && persistentFailureStartedAt
? now.getTime() - persistentFailureStartedAt.getTime()
: 0;
const shouldPark =
isPersistent && persistentStreakAgeMs > PARK_PERSISTENT_FAILURE_AFTER_MS;
// State mapping:
// reauth_required → error_reauth (sync entry-point short-circuits)
// transient → error_transient (next sync still runs)
// persistent → error_transient (next sync still runs, but the
// audit detail carries `kind: "persistent"` so
// operations can grep for contract-bug bursts)
const newState: IntegrationState =
kind === "reauth_required" ? "error_reauth" : "error_transient";
// Encrypt the error before persisting. crypto.encrypt() handles all
// edge cases (empty string, etc.) — we just have to give it a string.
const encryptedError = safeEncryptError(message);
// persistent → error_transient unless we just crossed the
// 24h park threshold, in which case → parked
// (sync entry-point short-circuits, audit detail
// carries the explicit kind so operations can
// grep for contract-bug bursts)
const newState: IntegrationState = shouldPark
? "parked"
: kind === "reauth_required"
? "error_reauth"
: "error_transient";
const row = await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
@@ -234,12 +447,16 @@ export async function recordSyncFailure(
lastAttemptAt: now,
lastError: encryptedError,
consecutiveFailures: 1,
consecutiveFailuresByKind: buckets,
persistentFailureStartedAt: isPersistent ? now : null,
},
update: {
state: newState,
lastAttemptAt: now,
lastError: encryptedError,
consecutiveFailures: { increment: 1 },
consecutiveFailuresByKind: buckets,
persistentFailureStartedAt,
},
});
@@ -255,6 +472,7 @@ export async function recordSyncFailure(
errorCode: errorCode ?? null,
message,
attemptNumber: row.consecutiveFailures,
bucketCount: buckets[kind],
state: newState,
},
});
@@ -266,8 +484,19 @@ export async function recordSyncFailure(
// Both conditions matter: (1) prevents premature paging, (2)
// prevents loops where a flapping integration that fails once an
// hour pages every hour.
//
// v1.4.43 W14 — the threshold check reads `Math.max(...buckets)` so
// a row with a 3-deep persistent streak still pages even when the
// transient bucket sat at 0 (the legacy integer was the running
// total and would have paged at the same moment).
const threshold = getPersistentFailureThreshold();
if (row.consecutiveFailures >= threshold) {
const alertSignal = Math.max(
row.consecutiveFailures,
buckets.transient,
buckets.reauth_required,
buckets.persistent,
);
if (alertSignal >= threshold) {
const previouslyAlerted =
row.alertedAt &&
now.getTime() - row.alertedAt.getTime() < ALERT_REPEAT_WINDOW_MS;
@@ -278,7 +507,7 @@ export async function recordSyncFailure(
kind,
message,
errorCode,
consecutiveFailures: row.consecutiveFailures,
consecutiveFailures: alertSignal,
}).catch((err) => {
getEvent()?.addWarning(
`Admin alert dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -293,6 +522,76 @@ export async function recordSyncFailure(
});
}
}
// Park-event audit row — written once per transition into the
// `parked` state so the operations trail shows "this integration
// disabled itself after 24h of persistent failures" without the
// operator having to correlate the sync.failed rows by timestamp.
if (shouldPark && existing?.persistentFailureStartedAt) {
await auditLog("integrations.parked", {
userId,
details: {
integration,
reason: "persistent_24h",
persistentFailureStartedAt:
existing.persistentFailureStartedAt.toISOString(),
persistentStreakAgeMs,
errorCode: errorCode ?? null,
message,
},
});
}
}
/**
* v1.4.43 W14 — clear a `parked` integration so the next scheduled
* sync runs again. Used by `/api/integrations/withings/resume` and
* the OAuth-callback path. The state moves to `connected`; all
* per-kind buckets reset to zero; the persistent-streak anchor
* clears; the alert window resets so the next genuine 3-strike burst
* still pages admins.
*
* Idempotent: calling against a connected row is a no-op (same
* post-state, no audit row). Calling against any non-parked error
* state also clears the row — the resume CTA is the universal
* "unstick this integration" button.
*/
export async function resumeIntegrationFromPark(
userId: string,
integration: IntegrationKey,
): Promise<{ wasParked: boolean }> {
const existing = await prisma.integrationStatus.findUnique({
where: { userId_integration: { userId, integration } },
select: { state: true },
});
const wasParked = existing?.state === "parked";
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
userId,
integration,
state: "connected",
consecutiveFailuresByKind: zeroBuckets(),
},
update: {
state: "connected",
lastError: null,
consecutiveFailures: 0,
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
alertedAt: null,
},
});
if (wasParked) {
await auditLog("integrations.resumed", {
userId,
details: { integration, source: "user_resume" },
});
}
return { wasParked };
}
/**
@@ -417,11 +716,14 @@ export async function markDisconnected(
userId,
integration,
state: "disconnected",
consecutiveFailuresByKind: zeroBuckets(),
},
update: {
state: "disconnected",
lastError: null,
consecutiveFailures: 0,
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
alertedAt: null,
},
});
@@ -442,11 +744,14 @@ export async function markReconnected(
userId,
integration,
state: "connected",
consecutiveFailuresByKind: zeroBuckets(),
},
update: {
state: "connected",
lastError: null,
consecutiveFailures: 0,
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
alertedAt: null,
},
});
@@ -526,6 +831,27 @@ const FAILURE_KIND_COPY: Record<
},
};
/**
* SECURITY INVARIANT (v1.4.43 W13 M-2 — MUST NOT be relaxed):
*
* The body produced here is dispatched to Telegram via
* `dispatchNotification`. `input.message` is upstream-influenced — the
* Withings classifier (`src/lib/withings/client.ts`) builds it as
* `Withings <verb> error: <status> - <json.error>` where `json.error`
* is whatever the upstream API put in the response body. Today that
* lands in Telegram on plain text (no `parseMode`), so the upstream
* string is rendered literally and a malicious / buggy response body
* is inert.
*
* Do NOT flip the Telegram callers downstream to `parseMode: "HTML"`
* or `"MarkdownV2"`. The medication-reminder paths use HTML mode
* because their bodies are server-built from sanitised data only; the
* admin-alert body is NOT sanitised. If HTML / Markdown parsing is
* ever enabled for this payload, escape every interpolated field
* (`input.message`, `subjectLabel`, `errorCode`) at the same time —
* otherwise an upstream-controlled string becomes an HTML / Markdown
* injection vector reaching every admin chat.
*/
export function formatAdminAlertPayload(input: AlertInput): {
title: string;
message: string;
+63
View File
@@ -4,6 +4,7 @@
*/
import { prisma } from "@/lib/db";
import { getClientIpOrTrustWarning } from "@/lib/api-response";
export interface RateLimitResult {
allowed: boolean;
@@ -62,3 +63,65 @@ export function rateLimitHeaders(
"X-RateLimit-Reset": new Date(result.resetAt).toISOString(),
};
}
/**
* v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every
* caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the
* actual proxy chain don't agree, `getClientIp` returns null and every
* caller falls back to a literal `"unknown"` rate-limit bucket. Any
* attacker can then exhaust the `auth:login:unknown` bucket and lock
* every other legitimate anonymous caller out of the surface.
*
* The tighter bucket is shared across every auth surface — a misconfigured
* deployment with the trust chain broken accepts at most this many
* anonymous attempts across login + register + passkey-verify + … per
* window. Operators see the once-per-process `console.warn` from
* `getClientIpOrTrustWarning` and fix the proxy chain; the tight bucket
* caps the blast radius while they do.
*/
const TIGHT_ANON_KEY = "auth:anon:trust-violation";
const TIGHT_ANON_LIMIT = 100;
const TIGHT_ANON_WINDOW_MS = 15 * 60 * 1000;
/**
* Rate-limit helper for anonymous auth surfaces (login, register,
* passkey-login-{options,verify}, refresh, check-user).
*
* Per-IP path: when the trust chain resolves cleanly, route the request
* to a bucket keyed by `{prefix}:{ip ?? "unknown"}` with the caller's
* supplied limit + window. Existing semantics, byte-for-byte.
*
* Tightened path: when `getClientIpOrTrustWarning` reports
* `trustViolation === true`, the configured proxy hops and the actual
* chain length don't match. Every anonymous caller already shares a
* single `"unknown"` bucket on the per-IP path; this helper instead
* routes them to a fixed global bucket with a strict 100/15min cap.
* One attacker can no longer exhaust the per-surface bucket — they hit
* the global cap first, and every other anonymous caller on any auth
* surface shares the same cap.
*
* The returned shape matches `checkRateLimit` so call sites can attach
* `rateLimitHeaders(rl)` unchanged.
*/
export async function checkAuthSurfaceRateLimit(
request: Request,
prefix: string,
perIpLimit: number,
windowMs: number,
): Promise<RateLimitResult & { ip: string | null }> {
const { ip, trustViolation } = getClientIpOrTrustWarning(request);
if (trustViolation) {
const result = await checkRateLimit(
TIGHT_ANON_KEY,
TIGHT_ANON_LIMIT,
TIGHT_ANON_WINDOW_MS,
);
return { ...result, ip };
}
const result = await checkRateLimit(
`${prefix}:${ip ?? "unknown"}`,
perIpLimit,
windowMs,
);
return { ...result, ip };
}