mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user