mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
1 parent
7bcb6f71d9
commit
bb563b6506
50 files changed
+1789
-142
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user