Answer the native client's open server asks

- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 10:32:11 +02:00
1 parent 7bcb6f71d9
commit bb563b6506
50 files changed
+1789 -142

No files matched your search

+16 -1
View File
@@ -74,9 +74,17 @@ import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { storedGlucoseUnit } from "@/lib/glucose";
export const dynamic = "force-dynamic";
/**
* Capability flags for clients that are not shipped with the server. Constant
* by design: a feature the server supports is supported for every account,
* and a per-account answer belongs in `modules` or `ai`, not here.
*/
const SERVER_FEATURES = { trackIntake: true } as const;
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
const { user } = auth;
@@ -331,7 +339,8 @@ export const GET = apiHandler(async () => {
avatarUrl: user.avatarUpdatedAt
? buildAvatarUrl(user.id, user.avatarUpdatedAt)
: null,
glucoseUnit: user.glucoseUnit ?? null,
// The stored choice, published as the closed pair the contract names.
glucoseUnit: storedGlucoseUnit(user.glucoseUnit),
// v1.7.0 — global metric/imperial display preference. Canonical
// storage stays SI; this only drives the display-time transform
// branch. Null defaults to "metric" on the client.
@@ -442,5 +451,11 @@ export const GET = apiHandler(async () => {
// behind it have one reader by design, and a route that projected them
// itself would be a second statement of what a record context is.
recordSession: recordSessionForPayload(auth),
// v1.42 — what this server understands, for a client released on its own
// schedule (iOS #116, item 18). `trackIntake: true` says medications take
// the record-only switch, so an add form can offer it before the account
// has a medication to learn it from. A flag only ever appears here once
// the server supports it, and stays `true`; absence means an older server.
features: SERVER_FEATURES,
});
});
@@ -0,0 +1,164 @@
/**
* A passkey sign-in that cannot be checked answers with its own status and
* `meta.errorCode`, never a 500. The real `verifyAuthentication` runs here
* against a mocked database, so the test pins the whole path from the
* library's refusal to the wire: an expired or already-used challenge, a
* credential this server does not know, a body that is not an assertion, and
* an assertion the WebAuthn library refuses.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
passkey: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
vi.mock("@simplewebauthn/server", async (importOriginal) => ({
...(await importOriginal<typeof import("@simplewebauthn/server")>()),
verifyAuthenticationResponse: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
const ASSERTION = {
id: "cred-1",
rawId: "cred-1",
type: "public-key",
response: {
clientDataJSON: "e30",
authenticatorData: "AA",
signature: "AA",
},
};
function request(credential: unknown = ASSERTION): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ challengeId: "ch-1", credential }),
});
}
async function refusal(res: Response) {
const body = (await res.json()) as {
data: null;
meta?: { errorCode?: string };
};
return { status: res.status, code: body.meta?.errorCode, data: body.data };
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ challenge: "c-1", user_id: null },
] as never);
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
id: "pk-1",
userId: "user-1",
credentialId: "cred-1",
credentialPublicKey: new Uint8Array([1]),
counter: BigInt(0),
transports: [],
} as never);
});
describe("POST /api/auth/passkey/login-verify — named refusals", () => {
it("answers an expired or already-used challenge with 401 passkey.challenge.expired", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
expect(await refusal(await POST(request()))).toEqual({
status: 401,
code: "passkey.challenge.expired",
data: null,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.login.failed",
expect.objectContaining({
details: { reason: "passkey_challenge_expired" },
}),
);
});
it("answers a challenge begun for another account the same way", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ challenge: "c-1", user_id: "someone-else" },
] as never);
const out = await refusal(await POST(request()));
expect(out.status).toBe(401);
expect(out.code).toBe("passkey.challenge.expired");
});
it("answers a passkey that is not registered here with 404 passkey.unknown", async () => {
vi.mocked(prisma.passkey.findUnique).mockResolvedValue(null as never);
expect(await refusal(await POST(request()))).toEqual({
status: 404,
code: "passkey.unknown",
data: null,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.login.failed",
expect.objectContaining({ details: { reason: "passkey_unknown" } }),
);
});
it("answers a body that is not an assertion with 422 passkey.response.invalid", async () => {
const out = await refusal(await POST(request({ id: "cred-1" })));
expect(out.status).toBe(422);
expect(out.code).toBe("passkey.response.invalid");
});
it("answers an assertion the library refuses with 401 passkey.verification.failed", async () => {
vi.mocked(verifyAuthenticationResponse).mockRejectedValue(
new Error("Unexpected authentication response origin"),
);
const out = await refusal(await POST(request()));
expect(out.status).toBe(401);
expect(out.code).toBe("passkey.verification.failed");
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("still lets a real fault surface as one", async () => {
vi.mocked(prisma.passkey.findUnique).mockRejectedValue(
new Error("connection reset"),
);
const res = await POST(request());
expect(res.status).toBe(500);
});
});
+69 -6
View File
@@ -1,4 +1,8 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import {
PasskeyAuthError,
verifyAuthentication,
type PasskeyAuthErrorKind,
} from "@/lib/auth/passkey";
import { createSession } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { recordSignInDevice } from "@/lib/auth/login-alert";
@@ -20,6 +24,43 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { isOidcOnly } from "@/lib/auth/oidc";
import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
const PASSKEY_VERIFICATION_FAILED_CODE = "passkey.verification.failed";
/**
* How each passkey refusal answers. The challenge and verification cases are
* 401 (the sign-in did not prove anything), the unknown credential is 404 (the
* passkey is not registered on this server), and a malformed body is 422.
*/
const PASSKEY_REFUSALS: Record<
PasskeyAuthErrorKind,
{ status: number; errorCode: string; message: string; auditReason: string }
> = {
challenge_expired: {
status: 401,
errorCode: "passkey.challenge.expired",
message: "The sign-in took too long or was already used. Try again.",
auditReason: "passkey_challenge_expired",
},
unknown_passkey: {
status: 404,
errorCode: "passkey.unknown",
message: "This passkey is not registered here.",
auditReason: "passkey_unknown",
},
malformed: {
status: 422,
errorCode: "passkey.response.invalid",
message: "The passkey response could not be read.",
auditReason: "passkey_response_invalid",
},
verification_failed: {
status: 401,
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
message: "Passkey verification failed",
auditReason: "passkey_verification_failed",
},
};
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block passkey login too, not just password login — a
// credential minted before SSO was mandated must not stay a live bypass.
@@ -55,17 +96,39 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("challengeId and credential required", 422);
}
const { verification, passkey } = await verifyAuthentication(
challengeId,
credential,
);
let outcome: Awaited<ReturnType<typeof verifyAuthentication>>;
try {
outcome = await verifyAuthentication(challengeId, credential);
} catch (err) {
// A known client-side case — an expired challenge, a passkey that is no
// longer registered here, a malformed or refused assertion — answers
// with its own status and code (iOS #116, item 20) so the app can tell
// the person what to do instead of reporting a server error. Anything
// else is a real fault and keeps propagating.
if (!(err instanceof PasskeyAuthError)) throw err;
const refusal = PASSKEY_REFUSALS[err.kind];
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: refusal.auditReason },
});
annotate({
action: { name: "auth.login.passkey.refused" },
meta: { reason: err.kind },
});
return apiError(refusal.message, refusal.status, {
errorCode: refusal.errorCode,
});
}
const { verification, passkey } = outcome;
if (!verification.verified) {
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: "passkey_verification_failed" },
});
return apiError("Passkey verification failed", 401);
return apiError("Passkey verification failed", 401, {
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
});
}
const user = await prisma.user.findUnique({