chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix

Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 12:51:41 +02:00
1 parent 30ab2112db
commit f92e644154
87 files changed
+7939 -2950

No files matched your search

@@ -0,0 +1,398 @@
/**
* v1.4.48 H-APNs-1 — coverage for the admin notification diagnostic
* endpoint. The route surfaces what the dispatcher would observe when
* targeting the calling admin's account so an operator can debug push
* delivery without DB-shell access; these tests pin:
*
* * 401 for an unauthenticated caller (no session cookie),
* * 403 for an authenticated non-admin (USER role),
* * 200 + the documented envelope shape for an admin with an empty
* device + channel set,
* * APNs token masking (prefix 8 + suffix 8 hex only, full token
* never returned),
* * per-channel `configPresent` reflecting the decrypted JSON blob
* for TELEGRAM / NTFY and the row-existence shortcut for APNS /
* WEB_PUSH (the latter two store delivery targets on sibling
* tables, not in the channel config blob).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
device: {
findMany: vi.fn(),
},
notificationChannel: {
findMany: vi.fn(),
},
// v1.4.49 — backing query for `recentPushAttempts`. The route
// pulls the trailing 20 rows ordered by `createdAt` DESC and maps
// the `createdAt` Date to an `at` ISO string for the response.
pushAttempt: {
findMany: vi.fn(),
},
},
}));
// The route imports requireAdmin from `@/lib/api-handler`. We keep
// apiHandler real so HttpError throws produce the proper 401 / 403
// JSON envelope, but stub requireAdmin to drive the three auth cases.
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: {
run: <T>(_evt: unknown, fn: () => Promise<T>) => fn(),
},
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
encrypt: vi.fn((value: string) => `enc(${value})`),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
const ADMIN_USER_ID = "admin-1";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: ADMIN_USER_ID,
username: "admin",
role: "ADMIN",
},
} as never;
interface DiagnosticEnvelope {
data: {
devices: Array<{
id: string;
platform: string;
hasApnsToken: boolean;
apnsTokenPrefix: string | null;
apnsTokenSuffix: string | null;
apnsEnvironment: string | null;
lastSeenAt: string;
}>;
notificationChannels: Array<{
type: string;
enabled: boolean;
configPresent: boolean;
}>;
recentPushAttempts: Array<{
eventType: string;
channel: string;
result: string;
reason: string | null;
at: string;
}>;
};
error: string | null;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
});
describe("GET /api/admin/notifications/diagnostic — auth gates", () => {
it("returns 401 when the caller has no session", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const res = await GET();
expect(res.status).toBe(401);
const body = (await res.json()) as { data: null; error: string };
expect(body.data).toBeNull();
expect(body.error).toBe("Not authenticated");
});
it("returns 403 when the caller is authenticated but not ADMIN", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
const res = await GET();
expect(res.status).toBe(403);
const body = (await res.json()) as { data: null; error: string };
expect(body.data).toBeNull();
expect(body.error).toBe("Admin access required");
});
});
describe("GET /api/admin/notifications/diagnostic — empty state", () => {
it("returns the documented envelope shape with empty arrays for an admin with no devices or channels", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.error).toBeNull();
expect(body.data).toEqual({
devices: [],
notificationChannels: [],
recentPushAttempts: [],
});
});
});
describe("GET /api/admin/notifications/diagnostic — device masking", () => {
it("returns hasApnsToken=true and 8/8-char prefix+suffix when device has an APNs token", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
const fullToken =
"abcdef1234567890aabbccddeeff0011223344556677889900112233445566778899aabb";
// Trim to a typical 64-char APNs hex token to keep the assertion
// realistic. The masker doesn't care about length beyond `>= 16`.
const token64 = fullToken.slice(0, 64);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
platform: "ios",
apnsToken: token64,
apnsEnvironment: "production",
lastSeen: new Date("2026-05-22T10:00:00.000Z"),
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.devices).toHaveLength(1);
const device = body.data.devices[0];
expect(device.hasApnsToken).toBe(true);
expect(device.apnsTokenPrefix).toBe(token64.slice(0, 8));
expect(device.apnsTokenSuffix).toBe(token64.slice(-8));
expect(device.apnsEnvironment).toBe("production");
expect(device.lastSeenAt).toBe("2026-05-22T10:00:00.000Z");
// Belt-and-braces: the full token must not appear anywhere in the
// serialised response.
const serialised = JSON.stringify(body);
expect(serialised).not.toContain(token64);
});
it("returns hasApnsToken=false and null prefix/suffix when the device row has no APNs token", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-2",
platform: "ios",
apnsToken: null,
apnsEnvironment: null,
lastSeen: new Date("2026-05-22T11:00:00.000Z"),
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
const device = body.data.devices[0];
expect(device.hasApnsToken).toBe(false);
expect(device.apnsTokenPrefix).toBeNull();
expect(device.apnsTokenSuffix).toBeNull();
});
});
describe("GET /api/admin/notifications/diagnostic — channel configPresent", () => {
it("returns configPresent=true for TELEGRAM when the decrypted blob has both botToken and chatId", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "TELEGRAM",
enabled: true,
config: 'enc({"botToken":"bot:12345","chatId":"678"})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.notificationChannels).toEqual([
{ type: "TELEGRAM", enabled: true, configPresent: true },
]);
});
it("returns configPresent=false for TELEGRAM when chatId is empty", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "TELEGRAM",
enabled: false,
config: 'enc({"botToken":"bot:12345","chatId":""})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "TELEGRAM", enabled: false, configPresent: false },
]);
});
it("returns configPresent=true for NTFY when topic is non-empty", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "NTFY",
enabled: true,
config: 'enc({"serverUrl":"https://ntfy.sh","topic":"hl-test"})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "NTFY", enabled: true, configPresent: true },
]);
});
it("returns configPresent=false for NTFY when topic is missing", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "NTFY",
enabled: true,
config: 'enc({"serverUrl":"https://ntfy.sh"})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "NTFY", enabled: true, configPresent: false },
]);
});
it("treats APNS and WEB_PUSH rows as configPresent=true (delivery target lives on sibling tables)", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{ type: "APNS", enabled: true, config: "enc({})" },
{ type: "WEB_PUSH", enabled: false, config: "enc({})" },
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "APNS", enabled: true, configPresent: true },
{ type: "WEB_PUSH", enabled: false, configPresent: true },
]);
});
it("returns configPresent=false when the encrypted blob is malformed (decryption throws)", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
// Our crypto mock unwraps `enc(...)`; pass a value that yields
// non-JSON after the unwrap so JSON.parse throws and the helper's
// try/catch returns false.
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{ type: "TELEGRAM", enabled: true, config: "not-encrypted-junk" },
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.notificationChannels).toEqual([
{ type: "TELEGRAM", enabled: true, configPresent: false },
]);
});
});
describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => {
it("returns recentPushAttempts as an empty array when the user has no rows", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.recentPushAttempts).toEqual([]);
});
it("maps push_attempts rows to the documented response shape (createdAt → at ISO string)", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
// The route's findMany select returns `createdAt` as a Date; the
// mapping step normalises to an ISO string under `at`.
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([
{
eventType: "MEDICATION_REMINDER",
channel: "APNS",
result: "ok",
reason: null,
createdAt: new Date("2026-05-22T09:00:00.000Z"),
},
{
eventType: "MOOD_REMINDER",
channel: "TELEGRAM",
result: "error",
reason: "telegram_blocked_by_user",
createdAt: new Date("2026-05-22T08:30:00.000Z"),
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.recentPushAttempts).toEqual([
{
eventType: "MEDICATION_REMINDER",
channel: "APNS",
result: "ok",
reason: null,
at: "2026-05-22T09:00:00.000Z",
},
{
eventType: "MOOD_REMINDER",
channel: "TELEGRAM",
result: "error",
reason: "telegram_blocked_by_user",
at: "2026-05-22T08:30:00.000Z",
},
]);
});
it("scopes the push-attempt query to the calling admin's userId, ordered desc by recency, capped at 20", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
await GET();
expect(vi.mocked(prisma.pushAttempt.findMany)).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
where: { userId: string };
orderBy: { createdAt: "desc" };
take: number;
};
expect(args.where.userId).toBe(ADMIN_USER_ID);
expect(args.orderBy).toEqual({ createdAt: "desc" });
expect(args.take).toBe(20);
});
});
@@ -0,0 +1,219 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import type { ChannelType } from "@/lib/notifications/types";
export const dynamic = "force-dynamic";
/**
* v1.4.48 H-APNs-1 — admin-only diagnostic endpoint for the
* notification subsystem. Surfaces what the dispatcher would see when
* targeting the calling admin's account so an operator can debug an
* iOS / Web Push / Telegram / ntfy issue without needing DB shell
* access. This fills the gap that bottlenecked the v1.4.47 APNs hunt
* — every "is the token there?" / "is the channel enabled?" question
* was a Coolify exec round-trip.
*
* Response shape:
* {
* devices: Device[], — APNs token presence + masked
* prefix/suffix only; never the
* full token.
* notificationChannels: Channel[] — per-channel enabled + whether
* the channel-specific config
* blob is present.
* recentPushAttempts: PushAttempt[] — last 20 push deliveries for
* the calling user, ordered by
* recency. Backed by the
* `push_attempts` table — every
* sender (APNS, WEB_PUSH,
* TELEGRAM, NTFY) writes one
* fire-and-forget row per
* dispatch. v1.4.49 closed the
* empty-array stub by adding
* the table + the write path.
* }
*
* Security: admin-only (cookie auth, never Bearer). The route never
* returns the full APNs token — only an 8-char hex prefix + 8-char
* suffix so the operator can correlate with iOS-side diagnostics.
*/
interface DiagnosticDevice {
id: string;
platform: string;
hasApnsToken: boolean;
apnsTokenPrefix: string | null;
apnsTokenSuffix: string | null;
apnsEnvironment: string | null;
lastSeenAt: string;
}
interface DiagnosticChannel {
type: ChannelType | string;
enabled: boolean;
configPresent: boolean;
}
interface DiagnosticPushAttempt {
eventType: string;
channel: string;
result: string;
reason: string | null;
at: string;
}
interface DiagnosticPayload {
devices: DiagnosticDevice[];
notificationChannels: DiagnosticChannel[];
recentPushAttempts: DiagnosticPushAttempt[];
}
/**
* Map an APNs hex token to its 8-char prefix + 8-char suffix. APNs
* device tokens are 64-char hex strings; we surface 8 + 8 = 16 chars
* total (25 % of the token), which is enough for the operator to
* cross-check against an iOS-side log line without disclosing the
* delivery target. Tokens shorter than 16 chars are treated as opaque
* (prefix / suffix both null) — that shape never appears in
* production, but defensive against partial inserts during testing.
*/
function maskApnsToken(
token: string | null,
): { prefix: string | null; suffix: string | null } {
if (!token || token.length < 16) return { prefix: null, suffix: null };
return {
prefix: token.slice(0, 8),
suffix: token.slice(-8),
};
}
/**
* Decide whether the channel's encrypted-JSON config blob carries the
* key the corresponding sender will actually read. We decrypt + parse
* inside a try/catch so a corrupted ciphertext row (or an upstream
* encryption-key rotation) cannot 500 the diagnostic endpoint — the
* worst case is `configPresent: false` and the operator investigates
* why the row is unreadable.
*
* The required field per channel type:
* - TELEGRAM → `chatId` + `botToken`
* - NTFY → `topic`
* - WEB_PUSH → no per-channel blob (subscriptions live in the
* `push_subscriptions` table); treat the row's mere
* existence as `configPresent: true`.
* - APNS → no per-channel blob (device tokens live on the
* `devices` table); treat the row's mere existence as
* `configPresent: true`.
*/
function isChannelConfigPresent(
type: string,
encryptedConfig: string,
): boolean {
if (type === "WEB_PUSH" || type === "APNS") {
// These channels store their delivery target on sibling tables
// (`push_subscriptions` / `devices`), not in the channel-row
// config blob. The row's existence is the signal.
return true;
}
try {
const parsed = JSON.parse(decrypt(encryptedConfig)) as Record<
string,
unknown
>;
if (type === "TELEGRAM") {
return (
typeof parsed.chatId === "string" &&
parsed.chatId.length > 0 &&
typeof parsed.botToken === "string" &&
parsed.botToken.length > 0
);
}
if (type === "NTFY") {
return typeof parsed.topic === "string" && parsed.topic.length > 0;
}
return false;
} catch {
return false;
}
}
export const GET = apiHandler(async () => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.notifications.diagnostic" } });
const [devices, channels, pushAttempts] = await Promise.all([
prisma.device.findMany({
where: { userId: user.id },
select: {
id: true,
platform: true,
apnsToken: true,
apnsEnvironment: true,
lastSeen: true,
},
orderBy: { lastSeen: "desc" },
}),
prisma.notificationChannel.findMany({
where: { userId: user.id },
select: { type: true, enabled: true, config: true },
}),
// v1.4.49 — trailing 20 push attempts for the calling admin's own
// account. The `(user_id, created_at DESC)` index on `push_attempts`
// turns the orderBy + take into a single index scan, so the cost is
// bounded regardless of the user's lifetime push volume.
prisma.pushAttempt.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 20,
select: {
eventType: true,
channel: true,
result: true,
reason: true,
createdAt: true,
},
}),
]);
const diagnosticDevices: DiagnosticDevice[] = devices.map((d) => {
const { prefix, suffix } = maskApnsToken(d.apnsToken);
return {
id: d.id,
platform: d.platform,
hasApnsToken: d.apnsToken !== null && d.apnsToken !== "",
apnsTokenPrefix: prefix,
apnsTokenSuffix: suffix,
apnsEnvironment: d.apnsEnvironment,
lastSeenAt: d.lastSeen.toISOString(),
};
});
const diagnosticChannels: DiagnosticChannel[] = channels.map((c) => ({
type: c.type,
enabled: c.enabled,
configPresent: isChannelConfigPresent(c.type, c.config),
}));
// v1.4.49 — backed by the `push_attempts` table. Map `createdAt`
// → `at` ISO string so the response shape matches the
// `DiagnosticPushAttempt` interface above (the existing consumer
// contract uses `at`, not `createdAt`).
const recentPushAttempts: DiagnosticPushAttempt[] = pushAttempts.map((a) => ({
eventType: a.eventType,
channel: a.channel,
result: a.result,
reason: a.reason,
at: a.createdAt.toISOString(),
}));
const payload: DiagnosticPayload = {
devices: diagnosticDevices,
notificationChannels: diagnosticChannels,
recentPushAttempts,
};
return apiSuccess(payload);
});
+11 -4
View File
@@ -305,10 +305,12 @@ describe("GET /api/analytics", () => {
// contract the dashboard tile strip reads.
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
// v1.4.36 — slim slice now opens with a cheap `SELECT COUNT(*) FROM
// measurement_rollups` probe. Force `n: 0` so the route takes the
// legacy heavy-aggregate fallback path that this test's fixtures
// expect (aggregate + latest, two `$queryRaw` passes).
// v1.4.36 — slim slice opens with a cheap `SELECT COUNT(*) FROM
// measurement_rollups` probe; `n: 0` forces the cold-fallback
// path. v1.4.48 M0 split that path's single aggregate query into
// two parallel ones (all-time + windowed), so the cold fixture
// now mocks 4 `$queryRaw` calls: coverage + allTime + windowed +
// latest.
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([{ n: BigInt(0) }] as never)
.mockResolvedValueOnce([
@@ -318,6 +320,11 @@ describe("GET /api/analytics", () => {
min_value: 80.0,
max_value: 84.5,
mean_value: 82.1,
},
] as never)
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82.0,
avg30: 82.2,
slope7: -0.05,
+15 -12
View File
@@ -9,7 +9,7 @@
*
* The toggle is reachable from Settings → Insights as a `<Switch>`
* labelled "Hide Coach" / "Coach ausblenden". Default `false` (Coach
* visible) — see migration `0076_v1447_user_disable_coach` for the
* visible) — see migration `0078_v1447_user_disable_coach` for the
* column-level documentation.
*
* Idempotent. The endpoint always returns the resolved next-state
@@ -19,13 +19,22 @@
* batch settings round-trips after a passkey re-pair and a tight
* 5/min would surface as a 429 on a normal onboarding flow.
*/
import { z } from "zod";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
const patchBodySchema = z.object({ disableCoach: z.boolean() });
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
@@ -72,19 +81,13 @@ export const PATCH = apiHandler(async (req: Request) => {
throw new HttpError(422, "disable-coach.body.invalid_json");
}
// Hand-rolled validation — single boolean field. Keeps the
// prompt-injection surface visibly empty in code review (no string
// ever reaches an LLM).
if (
!body ||
typeof body !== "object" ||
typeof (body as { disableCoach?: unknown }).disableCoach !== "boolean"
) {
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.disable-coach.patch.invalid_shape" } });
throw new HttpError(422, "disable-coach.body.invalid_shape");
return returnAllZodIssues(parsed.error, 422);
}
const next = (body as { disableCoach: boolean }).disableCoach;
const next = parsed.data.disableCoach;
const previous = await prisma.user.findUnique({
where: { id: user.id },
@@ -0,0 +1,270 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
toJson: <T,>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/notification-prefs", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns the documented defaults for a fresh user (null row)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: false } });
});
it("returns the resolved prefs when the row holds a value", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
});
it("returns defaults when the persisted shape has drifted", async () => {
// Forward-compat: an admin hand-edit / future-rename should not
// crash the GET.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { unknownCategory: { foo: "bar" } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: false } });
});
});
describe("PATCH /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("flips medication.clientManaged on and writes the audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
},
});
expect(auditLog).toHaveBeenCalledWith(
"user.notification-prefs.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: { medication: { clientManaged: false } },
next: { medication: { clientManaged: true } },
changed: ["medication"],
}),
}),
);
});
it("rejects malformed JSON with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request(
"http://localhost/api/auth/me/notification-prefs",
{
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
},
);
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a non-boolean clientManaged with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: "yes" } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("deep-merges over existing siblings without overwriting them", async () => {
// Forward-compat: a row that already carries an unknown sibling
// category (e.g. a future "mood" key persisted by a newer client)
// must survive a PATCH that only touches `medication`. The route
// parses with the current zod schema first, then deep-merges the
// input over the parsed (defaulted) base. Today, "unknown sibling"
// resolves back to defaults; the test pins that the medication
// PATCH still lands and that future schema growth (adding new
// siblings to the zod shape) will preserve them.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: false } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
},
});
});
it("returns the merged shape unchanged on an empty PATCH body", async () => {
// Idempotent — an empty body keeps the row as-is and still writes
// the audit trail (mirrors the disable-coach posture).
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({}),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
},
});
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,133 @@
/**
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences endpoint.
*
* GET /api/auth/me/notification-prefs — current resolved prefs.
* PATCH /api/auth/me/notification-prefs — body `{ medication?: { clientManaged?: boolean } }`.
* Deep-merges the supplied
* shape over the persisted
* row so future categories
* slot in next to
* `medication` without
* overwriting siblings.
*
* Auth is shared via `requireAuth()` so cookie sessions + Bearer
* tokens both work (the iOS app uses Bearer). Default shape lives in
* `parseNotificationPrefs` — a null DB row resolves to
* `{ medication: { clientManaged: false } }`, preserving the legacy
* server-side reminder behaviour for any user the iOS client has not
* yet opted in.
*
* Idempotent. The endpoint always returns the fully-resolved next
* state so the client can hard-set the optimistic update without an
* extra round-trip. Rate-limit is intentionally generous (60/min) —
* matches the disable-coach route which is the closest analogue.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
notificationPrefsSchema,
parseNotificationPrefs,
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.notification-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
});
return apiSuccess(parseNotificationPrefs(row?.notificationPrefs ?? null));
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`notification-prefs:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "notification-prefs.body.invalid_json");
}
const parsed = notificationPrefsSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.notification-prefs.patch.invalid_shape" },
meta: { issues: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
// Deep-merge the partial input over the current row so a PATCH that
// only touches `medication` does not overwrite future sibling
// categories (mood, personal_records, ...). The full resolved shape
// is persisted so the column layout stays stable across schema
// additions.
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
});
const previous = parseNotificationPrefs(current?.notificationPrefs ?? null);
const merged = resolveNotificationPrefs(
current?.notificationPrefs ?? null,
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
data: { notificationPrefs: toJson(merged) },
});
// Capture which categories the PATCH actually touched so the audit
// trail records the user's intent, not just the full resolved row.
const changedCategories = Object.keys(parsed.data);
await auditLog("user.notification-prefs.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous,
next: merged,
changed: changedCategories,
},
});
annotate({
action: { name: "user.notification_prefs.update" },
meta: {
changed: changedCategories,
medication_client_managed: merged.medication.clientManaged,
},
});
return apiSuccess(merged);
});
+1 -1
View File
@@ -34,7 +34,7 @@ export const GET = apiHandler(async () => {
lastReportPracticeName: user.lastReportPracticeName ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
// 0076 commentary). Every Coach mount point on the client checks
// 0078 commentary). Every Coach mount point on the client checks
// `user.disableCoach` BELOW the operator-level `flags.coach`
// short-circuit; both gates must agree to paint the affordance.
disableCoach: user.disableCoach ?? false,
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -94,6 +94,14 @@ beforeEach(() => {
} as never);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
// assertion inside an `it` that opted into fake timers would otherwise
// leak the fake clock into the next test in the file (or, worse, the
// next file in the same vitest worker).
afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/dashboard/summary", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -653,7 +661,7 @@ describe("GET /api/dashboard/summary", () => {
data: { compliance: { scheduledToday: number; takenToday: number } };
};
expect(body.data.compliance.scheduledToday).toBe(1);
vi.useRealTimers();
// v1.4.48 L13 — suite-level `afterEach` restores real timers.
});
it("computes intake compliance for today", async () => {
@@ -36,6 +36,15 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
};
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -48,6 +57,7 @@ vi.mock("next/headers", () => ({
import { PUT, __resetAuditDedupMemoForTests } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
@@ -135,13 +145,14 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("dashboard.widgets.validation-failed");
const details = JSON.parse(call.data.details) as {
issues: Array<{ path: string; code: string; message: string }>;
issues: Array<{ path: string; code: string }>;
};
expect(details.issues.length).toBe(2);
for (const issue of details.issues) {
// Audit row carries the same sanitised shape — issue.params never
// hits the ledger either.
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
// v1.4.49 — audit row is the persisted surface. `message`
// strips here so a future Zod code that embeds the offending
// value cannot leak into the ledger.
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
@@ -165,6 +176,58 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(body2.details.issues.length).toBe(2);
});
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-1)", async () => {
const payload = { version: 2, widgets: [], extraGarbage: "from-ios" };
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
// Top-level keys mirror the iOS-sent payload (NOT the schema keys).
expect(meta.received_keys).toEqual(
expect.arrayContaining(["version", "widgets", "extraGarbage"]),
);
// Excerpt is a JSON-stringified prefix, hard-capped at 256 chars.
expect(typeof meta.received_shape_excerpt).toBe("string");
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
expect(meta.received_shape_excerpt as string).toContain("\"version\":2");
// zod_issues is the same sanitised array surfaced under details.issues.
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
expect(Array.isArray(issues)).toBe(true);
expect(issues.length).toBeGreaterThanOrEqual(2);
for (const issue of issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("caps received_shape_excerpt at 256 chars even for a large iOS payload", async () => {
const widgets = Array.from({ length: 30 }, (_, i) => ({
id: `widget-${i}-${"x".repeat(20)}`,
visible: true,
order: i,
}));
const res = await callPut(makeReq({ version: 99, widgets }));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
it("does not block the 422 response when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
@@ -178,4 +241,65 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
};
expect(body.details.issues.length).toBe(2);
});
it("redacts sensitive keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
// Caller adds a credential-shaped field — must not land in the
// wide-event excerpt verbatim. The denylist also covers nested
// members so `payload.apiKey` is redacted, while `version` /
// `widgets` stay readable for operator debug.
const payload = {
version: 2,
widgets: [],
apnsToken: "ff".repeat(32),
authorization: "Bearer leaked",
payload: { apiKey: "sk_live_xxx" },
};
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
// Sensitive values are never written into the excerpt.
expect(excerpt).not.toContain("ff".repeat(32));
expect(excerpt).not.toContain("Bearer leaked");
expect(excerpt).not.toContain("sk_live_xxx");
// The redactor leaves the literal sentinel behind so an operator
// can still see the shape of the rejected payload.
expect(excerpt).toContain("[redacted]");
// Non-sensitive keys still surface for debugging.
expect(meta.received_keys).toEqual(
expect.arrayContaining([
"version",
"widgets",
"apnsToken",
"authorization",
"payload",
]),
);
});
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
// The wide-event meta keeps full message for operator debugging,
// but the persisted auditLog row must only carry `path` + `code`
// so a future Zod code that embeds the offending value cannot
// leak into the ledger.
await callPut(makeReq({ version: 2, widgets: [] }));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
});
+28 -2
View File
@@ -8,6 +8,7 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
safeJson,
returnAllZodIssues,
sanitiseZodIssues,
@@ -28,6 +29,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id rendered by the Settings →
@@ -145,9 +147,25 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// `/api/admin/audit` for these without combing the iOS dev
// console.
const issues = sanitiseZodIssues(parsed.error.issues);
// v1.4.48 H-iOS-1 — surface the iOS-sent payload shape alongside
// the Zod rejection so a single wide-event line carries enough
// detail to diagnose serialiser drift in `HealthLog-iOS`. We log
// ONLY the top-level keys plus a hard 256-char JSON excerpt — never
// the full body — so PII / token-like fields cannot leak. The
// diagnostic shape is built by the shared `buildPayloadDiagnostic`
// helper (v1.4.49) so the widget + series routes can't drift; the
// body is routed through `redactSensitiveFields` first so any
// future field matching the denylist (password / token / secret /
// apiKey / authorization / csrfState / nonce) lands as the literal
// `"[redacted]"` instead of its raw value.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
annotate({
action: { name: "dashboard.widgets.validation-failed" },
meta: { issue_count: issues.length },
meta: {
issue_count: issues.length,
...payloadDiagnostic,
zod_issues: issues,
},
});
// v1.4.43 B2 — gate the breadcrumb behind a 60 s (userId, action) dedup
// so a misbehaving iOS client retrying every second cannot pump
@@ -160,12 +178,20 @@ export const PUT = apiHandler(async (request: NextRequest) => {
)
) {
// Best-effort breadcrumb — never block the 422 on a write miss.
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value in their default message
// (`invalid_enum_value` and similar) cannot leak user content
// through the audit surface. The wide-event excerpt above
// already carries the shape signal for operator debugging.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.widgets.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -85,12 +85,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "devices.register.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row. Device
// registration carries `token` + `apnsToken`; the default Zod
// message for `invalid_format` echoes the offending value, which
// would persist an APNs token (or a forged near-miss) into the
// audit ledger.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "devices.register.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+7 -1
View File
@@ -103,12 +103,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "ingest.medication.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; external
// ingest carries free-text `medicationName` + opaque
// `idempotencyKey`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+7 -1
View File
@@ -152,12 +152,18 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
action: { name: "integrations.healthkit.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// HealthKit patch payload carries caller-provided `id` + `kind`
// strings that Zod can echo.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "integrations.healthkit.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -70,12 +70,20 @@ export const PUT = apiHandler(
action: { name: "measurements.update.validation-failed" },
meta: { issue_count: issues.length, measurement_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// update schema carries free-text `notes`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.update.validation-failed",
details: JSON.stringify({ issues, measurementId: id }),
details: JSON.stringify({
issues: auditIssues,
measurementId: id,
}),
},
})
.catch(() => {
+19 -3
View File
@@ -56,12 +56,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "measurements.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row to keep
// any free-text query slice (custom `dayKey`, attacker-shaped
// `type=`) out of the persisted breadcrumb.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -583,12 +589,17 @@ async function postMeasurement(request: NextRequest) {
action: { name: "measurements.create.batch.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; batch
// rows carry free-text `notes` per measurement.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.batch.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -671,12 +682,17 @@ async function postMeasurement(request: NextRequest) {
action: { name: "measurements.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; single
// measurement carries free-text `notes`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -16,6 +16,15 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
};
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -28,6 +37,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -157,6 +167,50 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
expect(call.data.action).toBe("measurements.series.validation-failed");
});
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-2)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("kind=garbage&days=0&extraGarbage=fromIos"));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect(meta.received_keys).toEqual(
expect.arrayContaining(["kind", "days", "extraGarbage"]),
);
expect(typeof meta.received_shape_excerpt).toBe("string");
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
expect(meta.received_shape_excerpt as string).toContain("\"kind\":\"garbage\"");
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
expect(Array.isArray(issues)).toBe(true);
expect(issues.length).toBeGreaterThanOrEqual(2);
for (const issue of issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("caps received_shape_excerpt at 256 chars even for a long iOS query", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const long = "x".repeat(500);
const res = await GET(req(`kind=garbage&days=0&junk=${long}`));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
@@ -165,4 +219,47 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
});
it("redacts sensitive query keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// An attacker-shaped query that mixes a valid `kind` with a
// credential-shaped `token` param. The excerpt must keep `kind`
// readable for operator debug but redact the token verbatim.
const res = await GET(
req("kind=garbage&days=0&token=hlk_secret_value&apiKey=sk_live_xxx"),
);
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
expect(excerpt).not.toContain("hlk_secret_value");
expect(excerpt).not.toContain("sk_live_xxx");
expect(excerpt).toContain("[redacted]");
// The key inventory still surfaces so operators see the shape.
expect(meta.received_keys).toEqual(
expect.arrayContaining(["kind", "days", "token", "apiKey"]),
);
});
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await GET(req("kind=garbage&days=0"));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
});
+26 -5
View File
@@ -14,11 +14,13 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
returnAllZodIssues,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType } from "@/generated/prisma/client";
const kindEnum = z.enum([
@@ -70,23 +72,42 @@ function stdDev(values: number[]): number {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
if (!parsed.success) {
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.series.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
// v1.4.48 H-iOS-2 — surface the iOS-sent query shape alongside
// the Zod rejection. Top-level keys + a hard 256-char JSON excerpt
// only; never the full payload, so token-shaped query params (we
// do not currently accept any but the truncation keeps that
// invariant cheap) cannot leak. Shared helper via v1.4.49 so the
// widget + series routes can't drift on the diagnostic shape; the
// query map is routed through `redactSensitiveFields` first so any
// future credential-shaped query string (`token`, `apiKey`,
// `csrfState`) redacts to `"[redacted]"` before the truncate.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(rawQuery));
annotate({
action: { name: "measurements.series.validation-failed" },
meta: { issue_count: issues.length },
meta: {
issue_count: issues.length,
...payloadDiagnostic,
zod_issues: issues,
},
});
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value (`invalid_enum_value` etc.)
// cannot leak user content through the audit surface.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.series.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -62,12 +62,20 @@ export const POST = apiHandler(
action: { name: "medications.intake.import.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; CSV
// imports carry caller-provided strings that Zod may echo.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.import.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
+17 -2
View File
@@ -49,12 +49,20 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
action: { name: "medications.intake.create.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// intake payload carries `idempotencyKey` (opaque caller string).
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.create.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
@@ -211,12 +219,19 @@ export const GET = apiHandler(
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -103,6 +103,14 @@ beforeEach(() => {
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
// assertion inside an `it` that opted into fake timers would otherwise
// leak the fake clock into the next test in the file (or, worse, the
// next file in the same vitest worker).
afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/medications/intake", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -262,7 +270,7 @@ describe("GET /api/medications/intake", () => {
);
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled();
vi.useRealTimers();
// v1.4.48 L13 — suite-level `afterEach` restores real timers.
});
it("returns compliance buckets for the last N days", async () => {
+5 -1
View File
@@ -120,12 +120,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
action: { name: "medications.intake.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.bulk.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+11 -2
View File
@@ -82,12 +82,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; iOS-sent
// query strings can flow into Zod issue messages.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -288,12 +293,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "medications.intake.update.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.update.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+6 -1
View File
@@ -75,12 +75,17 @@ export const PUT = apiHandler(
action: { name: "mood-entries.update.validation-failed" },
meta: { issue_count: issues.length, moodEntryId: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; mood
// update carries free-text `note` + `tags`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.update.validation-failed",
details: JSON.stringify({ issues, moodEntryId: id }),
details: JSON.stringify({ issues: auditIssues, moodEntryId: id }),
},
})
.catch(() => {
+6 -1
View File
@@ -122,12 +122,17 @@ async function postBulk(request: NextRequest): Promise<Response> {
action: { name: "mood.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; bulk mood
// entries carry free-text `note` + `tags` per row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood.bulk.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+16 -2
View File
@@ -44,12 +44,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "mood-entries.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value (`invalid_enum_value` etc.)
// cannot leak user-typed content. Mood-entries carry free-text
// `note` + `tags`; defensive strip everywhere mood content can
// reach a Zod issue.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -116,12 +124,18 @@ async function postMoodEntry(request: NextRequest) {
action: { name: "mood-entries.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mood-create schema's free-text `note` + `tags` could land in a
// Zod issue message verbatim.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -57,10 +57,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
: defaultLocale;
const t = getServerTranslator(locale).t;
// Send as MEDICATION_REMINDER so the dispatcher's time-sensitive /
// priority-10 branch fires. Otherwise the test runs at the default
// `active` interruption-level and iOS may summarise it into the
// Notification Center rather than presenting a lock-screen banner —
// leaving the user with no signal whether real medication reminders
// (which DO use time-sensitive) will surface on the lock screen.
// The title/body strings still read "Test notification" so the user
// doesn't mistake this for an actual scheduled dose.
const result = await sendViaApns(user.id, {
title: t("notifications.admin.testNotificationTitle"),
message: t("notifications.admin.testNotificationBody"),
eventType: "SYSTEM_ALERT",
eventType: "MEDICATION_REMINDER",
});
annotate({
+6 -1
View File
@@ -65,12 +65,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "tokens.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mint payload carries a free-text `name`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "tokens.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -0,0 +1,308 @@
/**
* v1.4.48 M3 — unit-level coverage for the atomic nonce-consumption
* branch.
*
* The integration tests in `tests/integration/withings-oauth*.test.ts`
* already pin the full handshake against real Postgres (happy path,
* replay, expired, cross-user). This file specifically targets the
* delete-first race window the M3 refactor closed: two concurrent
* callbacks with the same nonce — the first wins, the second hits
* Prisma P2025 and lands on the replay branch.
*
* Co-tests M7 — non-P2025 delete failures (DB-connection drop /
* integrity violation) surface via Wide-Event `addWarning` instead
* of being silently swallowed.
*
* v1.4.49 — assertions updated for the four-way reason-tag split
* (`csrf1`, `replay`, `expired`, `cross_user`) and the QA-2
* err.name-only warning template.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "user-1" },
session: { id: "sess-1" },
})),
HttpError: class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
},
}));
vi.mock("@/lib/db", () => ({
prisma: {
withingsOAuthState: {
delete: vi.fn(),
},
withingsConnection: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `enc(${s})`),
}));
vi.mock("@/lib/withings/client", () => ({
exchangeCode: vi.fn(),
WITHINGS_OAUTH_SCOPE: "user.metrics,user.activity",
}));
vi.mock("@/lib/withings/credentials", () => ({
getUserWithingsCredentials: vi.fn(),
}));
vi.mock("@/lib/withings/sync", () => ({
setupWebhook: vi.fn(() => Promise.resolve()),
}));
vi.mock("@/lib/integrations/status", () => ({
markReconnected: vi.fn(),
}));
const addWarning = vi.fn();
const annotateMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/logging/context", () => ({
annotate: annotateMock,
getEvent: vi.fn(() => ({ addWarning, setError: vi.fn() })),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
const NONCE = "abcdefghijklmnopqrstuv"; // 22-char base64url
const APP_URL = "http://localhost:3000";
function callbackRequest(state: string, cookieState: string): NextRequest {
return new NextRequest(
`http://localhost/api/withings/callback?code=auth-code-1&state=${encodeURIComponent(state)}`,
{
method: "GET",
headers: {
cookie: `withings_state=${cookieState}`,
},
},
);
}
beforeEach(() => {
vi.clearAllMocks();
process.env.NEXT_PUBLIC_APP_URL = APP_URL;
});
describe("withings/callback atomic nonce consumption (M3)", () => {
it("concurrent callbacks with the same nonce — only one succeeds, the second hits the P2025 replay branch", async () => {
// First callback: delete returns the row (win the race).
// Second callback: delete throws P2025 (row already consumed).
const winningRow = {
nonce: NONCE,
userId: "user-1",
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
createdAt: new Date(),
};
const p2025 = new Prisma.PrismaClientKnownRequestError(
"An operation failed because it depends on one or more records that were required but not found.",
{ code: "P2025", clientVersion: "x" },
);
const deleteMock = vi.mocked(prisma.withingsOAuthState.delete);
deleteMock
.mockResolvedValueOnce(winningRow as never)
.mockRejectedValueOnce(p2025);
// Stub the rest of the happy path for the winning request — code
// exchange returns plausible tokens so the upsert + redirect run.
const { getUserWithingsCredentials } = await import(
"@/lib/withings/credentials"
);
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
clientId: "cid",
clientSecret: "csec",
} as never);
const { exchangeCode } = await import("@/lib/withings/client");
vi.mocked(exchangeCode).mockResolvedValue({
userid: "wuid",
access_token: "atok",
refresh_token: "rtok",
expires_in: 3600,
} as never);
// Fire both legs concurrently. The mock's call ordering is FIFO,
// so whichever `await` hits the Prisma mock first gets the
// success result. Both legs use the same nonce + cookie value.
const reqA = callbackRequest(NONCE, NONCE);
const reqB = callbackRequest(NONCE, NONCE);
const [resA, resB] = await Promise.all([GET(reqA), GET(reqB)]);
// Both responses are 307 redirects.
expect(resA.status).toBe(307);
expect(resB.status).toBe(307);
// Exactly one delete-attempt per leg — the atomic shape collapses
// findUnique + delete into a single round-trip.
expect(deleteMock).toHaveBeenCalledTimes(2);
// Exactly one leg landed on `withings=connected`, exactly one
// landed on the replay branch (`withings=error&reason=replay`).
const locations = [resA, resB]
.map((r) => r.headers.get("location"))
.sort();
expect(locations).toHaveLength(2);
expect(
locations.some((l) => l?.includes("withings=connected")),
).toBe(true);
expect(
locations.some((l) => l?.includes("withings=error&reason=replay")),
).toBe(true);
// P2025 must NOT surface as a warning — it's the expected
// replay-branch signal, not a real infra problem.
expect(addWarning).not.toHaveBeenCalled();
// Wide-Event annotation carries the same reason so operators can
// grep without DB-shell.
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "replay" } });
});
it("non-P2025 delete failure (e.g. connection drop) surfaces a Wide-Event warning carrying err.name only, and bounces to the error page", async () => {
// M7 — a real infra failure on the consume-delete must not be
// silently swallowed.
//
// v1.4.49 QA-2 — the warning template now interpolates `err.name`
// rather than `${err}`. A Prisma error whose `message` echoes the
// offending value (e.g. the raw nonce) into the wide-event log
// would otherwise leak it.
class TimeoutError extends Error {
constructor() {
super("connection terminated unexpectedly nonce=abc123");
this.name = "TimeoutError";
}
}
vi.mocked(prisma.withingsOAuthState.delete).mockRejectedValueOnce(
new TimeoutError(),
);
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=state",
);
// The warning carries the error NAME so the audit trail can
// distinguish "row was gone" (P2025, no warning) from "DB unhappy"
// (warning, infra signal) without ever interpolating the message
// body.
expect(addWarning).toHaveBeenCalledTimes(1);
expect(addWarning).toHaveBeenCalledWith(
"oauth-state-delete failed: TimeoutError",
);
// Defensive: the message body (which Prisma may use to echo the
// offending value) must not appear in the warning.
expect(addWarning).not.toHaveBeenCalledWith(
expect.stringContaining("nonce=abc123"),
);
expect(addWarning).not.toHaveBeenCalledWith(
expect.stringContaining("connection terminated"),
);
});
it("delete succeeds but row has expired — validity check trips with reason=expired, no token exchange", async () => {
const expiredRow = {
nonce: NONCE,
userId: "user-1",
expiresAt: new Date(Date.now() - 60_000),
createdAt: new Date(),
};
vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce(
expiredRow as never,
);
const { exchangeCode } = await import("@/lib/withings/client");
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=expired",
);
// Single atomic round-trip consumed the row; no token exchange.
expect(exchangeCode).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "expired" } });
});
it("delete succeeds but row's userId mismatches session — validity check trips with reason=cross_user, no token exchange", async () => {
const crossUserRow = {
nonce: NONCE,
userId: "user-OTHER",
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
createdAt: new Date(),
};
vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce(
crossUserRow as never,
);
const { exchangeCode } = await import("@/lib/withings/client");
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=cross_user",
);
expect(exchangeCode).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "cross_user" } });
});
it("non-P2025 infra error branch annotates reason=state on the Wide Event (legacy fallback for unclassified DB failures)", async () => {
// v1.4.49 — the four named reasons (csrf1, replay, expired,
// cross_user) cover every "we know what went wrong" branch. A
// raw infra failure is intentionally still tagged `state` —
// operators reading the wide event will see the err.name in the
// warning and the `reason=state` annotation, which is enough to
// distinguish it from the four classified branches.
class TimeoutError extends Error {
constructor() {
super("connection terminated");
this.name = "TimeoutError";
}
}
vi.mocked(prisma.withingsOAuthState.delete).mockRejectedValueOnce(
new TimeoutError(),
);
const req = callbackRequest(NONCE, NONCE);
await GET(req);
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "state" } });
});
it("URL state vs cookie state mismatch short-circuits with reason=csrf1 BEFORE the atomic delete fires", async () => {
// CSRF leg 1 — must reject without touching the ledger so a
// probe can't grief legitimate rows by issuing deletes for
// nonces it doesn't legitimately hold the cookie for.
const req = callbackRequest("aaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbb");
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=csrf1",
);
expect(prisma.withingsOAuthState.delete).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "csrf1" } });
});
});
+88 -45
View File
@@ -8,6 +8,7 @@ import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state";
import { setupWebhook } from "@/lib/withings/sync";
import { markReconnected } from "@/lib/integrations/status";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
@@ -23,6 +24,17 @@ import { timingSafeEqual } from "node:crypto";
* runs. The row is consumed (deleted) on every exit branch so a
* replay of the same nonce fails the second time.
*
* v1.4.48 M3 — the row is now consumed via a single delete-first
* round-trip. Prisma `delete` returns the row on success, so the
* post-delete validity checks (`expiresAt`, `userId`) still run
* against the consumed payload. Two concurrent callbacks with the
* same nonce now race at the Postgres row level — only one delete
* wins, the other hits P2025 ("record not found") and is treated as
* a replay, bounced to the error page. Previously the `findUnique +
* separate delete` shape left a tiny RTT window where both legs
* could pass the lookup before either issued the delete, contrary
* to the docstring's atomicity promise.
*
* Closes the v1.4.43 security audit L-1 finding: the cookie now
* carries only the opaque nonce, so a future refactor flipping it to
* non-httpOnly cannot leak the user id into request logs / network
@@ -39,13 +51,72 @@ export const GET = apiHandler(async (request: NextRequest) => {
// CSRF check, leg 1: URL state must match the cookie state byte-for-byte.
// Timing-safe comparison so a probe can't recover the cookie bytes via
// response-time analysis.
// response-time analysis. This runs BEFORE the atomic delete so a
// probe with arbitrary state values can't grief legitimate ledger
// rows by triggering deletes for nonces it doesn't legitimately
// hold the cookie for.
//
// v1.4.49 — reason tags now distinguish the four post-delete branches
// so operators reading the audit trail can tell `csrf1` (URL/cookie
// mismatch, short-circuit before delete), `replay` (P2025 — nonce
// already consumed), `expired` (valid row but TTL elapsed), and
// `cross_user` (valid row but session userId mismatch) apart. A
// matching `meta.reason` Wide-Event annotation carries the same so
// operators can grep without DB-shell.
if (
!state ||
!storedState ||
state.length !== storedState.length ||
!timingSafeEqual(Buffer.from(state), Buffer.from(storedState))
) {
annotate({ meta: { reason: "csrf1" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=csrf1",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
// CSRF check, leg 2: atomically consume the ledger row. Prisma
// `delete` returns the row on success so `expiresAt` + `userId`
// checks still run against the consumed payload. P2025 ("record
// not found") means the nonce was already consumed by a prior
// callback (a replay) or never existed — both land on the same
// error redirect. Atomic at the Postgres row level: two concurrent
// callbacks with the same nonce can no longer both pass the
// lookup before either issues the delete.
let stateRow: { userId: string; expiresAt: Date } | null = null;
try {
stateRow = await prisma.withingsOAuthState.delete({
where: { nonce: state },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2025"
) {
// Row already consumed (replay) or never existed.
annotate({ meta: { reason: "replay" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=replay",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
// A real infra problem (connection drop, integrity violation)
// shouldn't be silently swallowed. Surface it to the Wide Event
// so the audit trail captures it, then bounce to the error
// page. Re-throwing would land on the apiHandler's 500 path and
// strand the user without a clean redirect.
//
// v1.4.49 QA-2 — the warning interpolates `err.name` rather than
// `${err}` so a Prisma error message echoing the offending value
// (e.g. the raw nonce) into the wide-event log cannot leak it.
const errName = err instanceof Error ? err.name : "unknown";
getEvent()?.addWarning(`oauth-state-delete failed: ${errName}`);
annotate({ meta: { reason: "state" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=state",
@@ -54,42 +125,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
// CSRF check, leg 2: the nonce must resolve to a live, unexpired
// ledger row whose `userId` matches the authenticated session.
// Resolving the user from the row (rather than parsing the cookie
// value) is the v1.4.43 audit recommendation — a future refactor
// flipping the cookie to non-httpOnly cannot leak the user id this
// way.
const stateRow = await prisma.withingsOAuthState.findUnique({
where: { nonce: state },
});
if (
!stateRow ||
stateRow.expiresAt <= new Date() ||
stateRow.userId !== user.id
) {
// Single-use: stamp out the row whether the expiry tripped, the
// user mismatched, or we found nothing. The user will retry from
// `withings/connect` with a fresh nonce.
if (stateRow) {
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
}
// Validity checks against the consumed row. The row is already
// gone from the ledger by this point — single-use semantics —
// whether the expiry tripped, the userId mismatched, or
// everything looked good.
if (stateRow.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=state",
"/settings/integrations?withings=error&reason=expired",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (stateRow.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=cross_user",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (!code) {
// Consume the row even on the no-code path so the nonce can't
// be replayed by an attacker who scraped the redirect URL.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocode",
@@ -101,9 +160,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
try {
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocreds",
@@ -156,13 +212,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
// don't write a fresh success-time — that's the next sync's job.
await markReconnected(user.id, "withings");
// Consume the ledger row — single-use semantics. From this point
// on a replay of the same `state` lands on the not-found branch
// above and redirects to the error page.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
const response = NextResponse.redirect(
new URL(
"/settings/integrations?withings=connected",
@@ -173,12 +222,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
return response;
} catch (err) {
getEvent()?.setError(err);
// Consume the row on the failure path too — the user is going to
// retry from `withings/connect` which mints a fresh nonce, and a
// stranded row buys us nothing.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=token",
@@ -0,0 +1,136 @@
/**
* v1.4.48 L5 + L14 — GET /api/withings/connect
*
* Pins:
* 1. Happy path: mints a nonce, creates a `WithingsOAuthState` row,
* redirects to the Withings authorize URL with the state cookie
* attached.
* 2. Rate-limit: the 11th call in a 60 s window redirects to
* `/settings/integrations?withings=error&reason=rate_limited`
* and does not call `prisma.withingsOAuthState.create`.
* 3. Row-create failure: a Prisma error inside the try/catch
* redirects to
* `/settings/integrations?withings=error&reason=connect`
* instead of bubbling a 500.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
withingsOAuthState: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/withings/credentials", () => ({
getUserWithingsCredentials: vi.fn(),
}));
vi.mock("@/lib/withings/client", () => ({
getAuthorizationUrl: vi.fn(
() => "https://account.withings.com/oauth2_user/authorize2?state=nonce-stub",
),
}));
vi.mock("@/lib/withings/oauth-state", () => ({
WITHINGS_OAUTH_STATE_COOKIE: "withings_oauth_state",
WITHINGS_OAUTH_STATE_TTL_MS: 10 * 60 * 1000,
mintWithingsOAuthStateNonce: vi.fn(() => "nonce-stub"),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => ({
setError: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
function connectRequest(): NextRequest {
return new NextRequest("http://localhost/api/withings/connect", {
method: "GET",
});
}
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.withingsOAuthState.create).mockReset();
vi.mocked(prisma.withingsOAuthState.create).mockResolvedValue(
{} as never,
);
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
clientId: "client-id",
clientSecret: "client-secret",
} as never);
});
describe("GET /api/withings/connect", () => {
it("redirects to the Withings authorize URL on the happy path", async () => {
const response = await GET(connectRequest());
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain(
"account.withings.com/oauth2_user/authorize2",
);
expect(prisma.withingsOAuthState.create).toHaveBeenCalledTimes(1);
expect(checkRateLimit).toHaveBeenCalledWith(
"withings:connect:u-1",
10,
60_000,
);
});
it("redirects to reason=rate_limited when the 11th call within 60 s is rejected", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await GET(connectRequest());
expect(response.status).toBe(307);
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("withings=error");
expect(location).toContain("reason=rate_limited");
expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled();
});
it("redirects to reason=connect when the row create throws", async () => {
vi.mocked(prisma.withingsOAuthState.create).mockRejectedValueOnce(
new Error("Prisma transient failure"),
);
const response = await GET(connectRequest());
expect(response.status).toBe(307);
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("withings=error");
expect(location).toContain("reason=connect");
});
});
+50 -10
View File
@@ -1,7 +1,8 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import {
@@ -9,7 +10,7 @@ import {
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
/**
* Redirects the user to Withings OAuth authorization page.
@@ -21,11 +22,39 @@ import { NextResponse } from "next/server";
* nonce, and the callback resolves the user via the row's `userId`
* column. Closes the v1.4.43 security audit L-1 finding where the
* user id was recoverable from log entries / network captures.
*
* v1.4.48 L5 + L14 — endpoint is now rate-limited per-user (10 calls
* per 60 s) so a logged-in user with valid creds can't spam ledger
* rows for the full 10-min TTL window. The row-create call is wrapped
* in try/catch; a transient DB glitch now lands on the existing
* `/settings/integrations?withings=error&reason=connect` surface
* instead of bubbling a 500. Both rate-limit and create-failure paths
* redirect rather than returning JSON because the entry point is a
* browser navigation — a 429 envelope would surface as a blank page.
*/
export const GET = apiHandler(async () => {
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async (req: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "withings.connect" } });
const rl = await checkRateLimit(
`withings:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "withings.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=rate_limited",
req.url,
),
);
}
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
return apiError(
@@ -35,13 +64,24 @@ export const GET = apiHandler(async () => {
}
const nonce = mintWithingsOAuthStateNonce();
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
try {
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
} catch (err) {
getEvent()?.setError(err);
annotate({ action: { name: "withings.connect.create_failed" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=connect",
req.url,
),
);
}
const url = getAuthorizationUrl(nonce, creds);