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:
Marc-André Bombeck authored and GitHub committed 2026-05-22 09:18:52 +02:00
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);
});
});
-7
View File
@@ -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();
});
});
+121
View File
@@ -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);
});
+6
View File
@@ -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 () => {
+1 -2
View File
@@ -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 () => {
+64 -7
View File
@@ -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",
+25 -7
View File
@@ -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 () => {
+7 -32
View File
@@ -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)`,
);