mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170.
This commit is contained in:
1 parent
ac319c6160
commit
66a7f8b6fa
68 files changed
+3959
-715
No files matched your search
@@ -45,16 +45,9 @@ vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
}));
|
||||
|
||||
// v1.4.25 W21 Fix-K — `/api/admin/status` now reads the legacy-form
|
||||
// counter from the Withings webhook module. Mock it at module boundary
|
||||
// so the test fixture stays deterministic.
|
||||
vi.mock("@/app/api/withings/webhook/route", () => ({
|
||||
getLegacyFormTotal: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -178,12 +171,4 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
|
||||
it("surfaces the legacy Withings webhook counter on the counters block (Fix-K sec-M2)", async () => {
|
||||
vi.mocked(getLegacyFormTotal).mockReturnValue(7);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { counters: { withingsWebhookLegacyFormTotal: number } };
|
||||
};
|
||||
expect(body.data.counters.withingsWebhookLegacyFormTotal).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Build timestamp (set at build time via next.config)
|
||||
@@ -92,11 +91,5 @@ export const GET = apiHandler(async () => {
|
||||
webPush: webPushConfigured ? { configured: true } : null,
|
||||
bugReport: bugReportConfigured ? { configured: true } : null,
|
||||
},
|
||||
// v1.4.25 W21 Fix-K — in-memory counters that the release-gate can
|
||||
// read to confirm "legacy form usage trending toward zero" before
|
||||
// the v1.4.27 cut of the legacy Withings webhook route.
|
||||
counters: {
|
||||
withingsWebhookLegacyFormTotal: getLegacyFormTotal(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
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/disable-coach", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/disable-coach", () => {
|
||||
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/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: false,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for an opted-out user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the column is missing (partial-deploy rollback)", async () => {
|
||||
// Defensive default — the API surface tolerates a `findUnique`
|
||||
// result that doesn't contain the column. Belt-and-braces with the
|
||||
// schema-level NOT NULL default; both layers keep the Coach
|
||||
// visible by default for users who never opted out.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips the column on and writes the enable audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(true);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { disableCoach: true },
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.disable-coach.enable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: false,
|
||||
next: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("flips the column off and writes the disable audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(false);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { disableCoach: false },
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.disable-coach.disable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: true,
|
||||
next: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a missing disableCoach field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ otherField: "ignored" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean disableCoach field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: "true" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const req = new Request("http://localhost/api/auth/me/disable-coach", {
|
||||
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("is idempotent when the column already holds the requested value", async () => {
|
||||
// Toggling a column from true→true (or false→false) still writes
|
||||
// through so the audit row exists. Matches the research-mode
|
||||
// route's "always write" posture.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* v1.4.47 W3 — per-user Coach opt-out endpoint.
|
||||
*
|
||||
* GET /api/auth/me/disable-coach — current flag.
|
||||
* PATCH /api/auth/me/disable-coach — body `{ disableCoach: boolean }`.
|
||||
* Flips the column and emits an
|
||||
* audit-log row mirroring the
|
||||
* Research-Mode endpoint pattern.
|
||||
*
|
||||
* 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
|
||||
* column-level documentation.
|
||||
*
|
||||
* Idempotent. The endpoint always returns the resolved next-state
|
||||
* shape so the client can hard-set the optimistic update without an
|
||||
* extra round-trip. Rate-limit is intentionally generous (60/min) —
|
||||
* the Switch is a one-tap affordance, but iOS clients may eventually
|
||||
* 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 { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } 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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
type DisableCoachResponse = {
|
||||
disableCoach: boolean;
|
||||
};
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.disable-coach.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { disableCoach: true },
|
||||
});
|
||||
const payload: DisableCoachResponse = {
|
||||
disableCoach: row?.disableCoach ?? false,
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`disable-coach: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, "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"
|
||||
) {
|
||||
annotate({ action: { name: "auth.me.disable-coach.patch.invalid_shape" } });
|
||||
throw new HttpError(422, "disable-coach.body.invalid_shape");
|
||||
}
|
||||
|
||||
const next = (body as { disableCoach: boolean }).disableCoach;
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { disableCoach: true },
|
||||
});
|
||||
|
||||
// Idempotent — write even when the values match so the audit row
|
||||
// mirrors the API call. The DB write is cheap and the audit log is
|
||||
// the source of truth for "user toggled this".
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { disableCoach: next },
|
||||
});
|
||||
|
||||
await auditLog(
|
||||
next ? "user.disable-coach.enable" : "user.disable-coach.disable",
|
||||
{
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous?.disableCoach ?? false,
|
||||
next,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.disable-coach.patch" },
|
||||
meta: { disableCoach: next },
|
||||
});
|
||||
|
||||
const payload: DisableCoachResponse = { disableCoach: next };
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -32,5 +32,11 @@ export const GET = apiHandler(async () => {
|
||||
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
|
||||
glucoseUnit: user.glucoseUnit ?? null,
|
||||
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
|
||||
// `user.disableCoach` BELOW the operator-level `flags.coach`
|
||||
// short-circuit; both gates must agree to paint the affordance.
|
||||
disableCoach: user.disableCoach ?? false,
|
||||
});
|
||||
});
|
||||
@@ -604,6 +604,13 @@ describe("GET /api/dashboard/summary", () => {
|
||||
// slot (e.g. the intake route or the reminder worker minted it
|
||||
// first), the dashboard route's backfill `createMany` must not
|
||||
// fire — the route simply re-reads + returns.
|
||||
//
|
||||
// v1.4.47 — pin the wall-clock so the projected scheduledFor and
|
||||
// the mocked existence row land on the same day regardless of
|
||||
// when the suite runs (previously hard-coded 2026-05-21 — broke
|
||||
// on 2026-05-22).
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-21T08:00:00.000Z"));
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const scheduledFor = new Date("2026-05-21T05:00:00.000Z");
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
@@ -646,6 +653,7 @@ describe("GET /api/dashboard/summary", () => {
|
||||
data: { compliance: { scheduledToday: number; takenToday: number } };
|
||||
};
|
||||
expect(body.data.compliance.scheduledToday).toBe(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("computes intake compliance for today", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Returns a snapshot per integration containing:
|
||||
* - the IntegrationStatus row (state, last-success, last-attempt,
|
||||
* decrypted last-error, consecutive failures, threshold)
|
||||
* decrypted last-error, per-kind failure buckets, threshold)
|
||||
* - integration-specific extras the UI already shows (Withings:
|
||||
* credentials configured, token expiry, OAuth-connected; moodLog:
|
||||
* credentials configured, enabled flag)
|
||||
@@ -92,7 +92,6 @@ interface IntegrationViewModel {
|
||||
lastSuccessAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
lastError: string | null;
|
||||
consecutiveFailures: number;
|
||||
}
|
||||
|
||||
interface WithingsExtras {
|
||||
|
||||
@@ -218,6 +218,12 @@ describe("GET /api/medications/intake", () => {
|
||||
});
|
||||
|
||||
it("does not double-mint when today's rows already exist", async () => {
|
||||
// v1.4.47 — pin the wall-clock so the projected scheduledFor and
|
||||
// the mocked existence row land on the same day regardless of
|
||||
// when the suite runs (previously hard-coded 2026-05-21 — broke
|
||||
// on 2026-05-22).
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-21T08:00:00.000Z"));
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const scheduledFor = new Date("2026-05-21T05:00:00.000Z");
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
@@ -256,6 +262,7 @@ describe("GET /api/medications/intake", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns compliance buckets for the last N days", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
|
||||
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 { NextRequest, NextResponse } from "next/server";
|
||||
@@ -12,6 +13,20 @@ import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* OAuth callback from Withings. Exchanges code for tokens and stores them.
|
||||
*
|
||||
* v1.4.47 W6 — `state` is now a fully-random 22-character base64url
|
||||
* nonce keyed against the `WithingsOAuthState` ledger, rather than the
|
||||
* legacy `${user.id}:${random16}` shape. The user identity bound to
|
||||
* the in-flight handshake is resolved via the ledger's `userId`
|
||||
* column (no longer parsed from the cookie value) and cross-checked
|
||||
* against the session — both must agree before the token exchange
|
||||
* runs. The row is consumed (deleted) on every exit branch so a
|
||||
* replay of the same nonce fails the second time.
|
||||
*
|
||||
* 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
|
||||
* captures.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -20,9 +35,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const storedState = request.cookies.get("withings_state")?.value;
|
||||
const storedState = request.cookies.get(WITHINGS_OAUTH_STATE_COOKIE)?.value;
|
||||
|
||||
// CSRF check (timing-safe comparison to prevent timing attacks)
|
||||
// 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.
|
||||
if (
|
||||
!state ||
|
||||
!storedState ||
|
||||
@@ -37,18 +54,42 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Verify state contains current user's ID
|
||||
const [stateUserId] = state.split(":");
|
||||
if (stateUserId !== user.id) {
|
||||
// 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(() => {});
|
||||
}
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=user",
|
||||
"/settings/integrations?withings=error&reason=state",
|
||||
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",
|
||||
@@ -60,6 +101,9 @@ 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",
|
||||
@@ -112,16 +156,29 @@ 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",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
response.cookies.delete("withings_state");
|
||||
response.cookies.delete(WITHINGS_OAUTH_STATE_COOKIE);
|
||||
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",
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
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 { getAuthorizationUrl } from "@/lib/withings/client";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
WITHINGS_OAUTH_STATE_COOKIE,
|
||||
WITHINGS_OAUTH_STATE_TTL_MS,
|
||||
mintWithingsOAuthStateNonce,
|
||||
} from "@/lib/withings/oauth-state";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Redirects the user to Withings OAuth authorization page.
|
||||
* State param = userId:random for CSRF protection.
|
||||
*
|
||||
* v1.4.47 W6 — state is now a fully-random 22-character base64url
|
||||
* nonce backed by a 10-minute `WithingsOAuthState` row, rather than
|
||||
* the legacy `${user.id}:${random16}` shape. The cookie carries JUST
|
||||
* the nonce (no user id), the OAuth state URL param carries JUST the
|
||||
* 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.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -22,17 +34,23 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
}
|
||||
|
||||
const stateNonce = randomBytes(16).toString("hex");
|
||||
const state = `${user.id}:${stateNonce}`;
|
||||
const nonce = mintWithingsOAuthStateNonce();
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
const url = getAuthorizationUrl(state, creds);
|
||||
const url = getAuthorizationUrl(nonce, creds);
|
||||
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set("withings_state", state, {
|
||||
response.cookies.set(WITHINGS_OAUTH_STATE_COOKIE, nonce, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 600,
|
||||
maxAge: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000),
|
||||
path: "/",
|
||||
});
|
||||
|
||||
|
||||
@@ -45,13 +45,7 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
POST,
|
||||
GET,
|
||||
HEAD,
|
||||
getLegacyFormTotal,
|
||||
__resetLegacyFormTotalForTests,
|
||||
} from "../route";
|
||||
import { POST, GET, HEAD } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { syncUserActivity } from "@/lib/withings/sync-activity";
|
||||
@@ -174,7 +168,6 @@ describe("POST /api/withings/webhook", () => {
|
||||
});
|
||||
|
||||
it("legacy ?secret=… query path still authorises and emits a Wide Event warning with the migration URL (Fix-K sec-M2)", async () => {
|
||||
__resetLegacyFormTotalForTests();
|
||||
const addWarning = vi.fn();
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
@@ -198,25 +191,6 @@ describe("POST /api/withings/webhook", () => {
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/withings/webhook/[token]"),
|
||||
);
|
||||
// Fix-K sec-M2: in-memory counter increments on every legacy
|
||||
// ?secret=… call so the release-gate can watch usage trend to zero.
|
||||
expect(getLegacyFormTotal()).toBe(1);
|
||||
});
|
||||
|
||||
it("counter stays at zero when the secret arrives via the header (Fix-K sec-M2)", async () => {
|
||||
__resetLegacyFormTotalForTests();
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-h",
|
||||
withingsUserId: "wu-h",
|
||||
} as never);
|
||||
|
||||
await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-h" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(getLegacyFormTotal()).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts form-encoded payloads (Withings legacy delivery format)", async () => {
|
||||
|
||||
@@ -28,41 +28,17 @@ import {
|
||||
* while users re-subscribe (which also happens organically when they
|
||||
* reconnect for the new `user.activity` OAuth scope shipped in W5d).
|
||||
*
|
||||
* Removal target: v1.4.27 (after all live subscriptions have rotated).
|
||||
* Removal target: a future release once all live subscriptions have
|
||||
* rotated to the path-segment form. The warning is still emitted on the
|
||||
* legacy path so an operator watching the access log knows what to
|
||||
* migrate to.
|
||||
*
|
||||
* v1.4.25 W21 Fix-K — added an in-memory `withings.webhook.legacy_form_total`
|
||||
* counter the `/api/admin/status` endpoint surfaces so the release-gate
|
||||
* can read "legacy form usage trending toward zero" before the v1.4.27
|
||||
* cut. The warning text now includes the re-subscription URL so anyone
|
||||
* watching the access log knows what to migrate to.
|
||||
* v1.4.47 — dropped the in-memory `legacy_form_total` counter (was per-
|
||||
* process, useless on a multi-container deploy; the access-log warning
|
||||
* already covers the signal it provided).
|
||||
*/
|
||||
const MIGRATION_URL = "/api/withings/webhook/[token]" as const;
|
||||
|
||||
// In-memory counter — resets on every deploy / cold-start. That is the
|
||||
// intended granularity: we want to know "how many legacy-form requests
|
||||
// did this process serve since it came up", not a forever-running
|
||||
// cumulative metric (which would belong in Prisma).
|
||||
const counters = {
|
||||
legacy_form_total: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the legacy-form counter. Exported so `/api/admin/status` can
|
||||
* surface it without a backchannel.
|
||||
*/
|
||||
export function getLegacyFormTotal(): number {
|
||||
return counters.legacy_form_total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the legacy-form counter. Test-only utility; production code
|
||||
* does not call this. Documented so a future smoke test can wire up
|
||||
* a deterministic "counter was 0 before the call" assertion.
|
||||
*/
|
||||
export function __resetLegacyFormTotalForTests(): void {
|
||||
counters.legacy_form_total = 0;
|
||||
}
|
||||
|
||||
function hasValidWebhookSecret(
|
||||
request: NextRequest,
|
||||
): Promise<{ ok: boolean; via: "header" | "query" | "none" }> {
|
||||
@@ -89,7 +65,6 @@ async function checkAndWarn(
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (auth.via === "query") {
|
||||
counters.legacy_form_total += 1;
|
||||
getEvent()?.addWarning(
|
||||
`withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user