Publish the passkey list and the base-token 422

GET /api/auth/passkeys was absent from the contract entirely — no path
entry, no schema — while the client teams had been pointed at a published
schema for it since v1.32.8. It is now registered with the shape the route
actually serves: id, name, credentialDeviceType, credentialBackedUp,
createdAt, and the nullable lastUsedAt that started this. The route itself
is unchanged; only the record of it was missing. PasskeyInfo is deliberately
separate from WebauthnMfaCredentialInfo — one lists sign-in credentials, the
other second-factor security keys, and merging them would blur two surfaces
that behave differently. The route test now compares the projection the
route asks Prisma for against the published property set, so neither end can
move without the other.

`invalid_base_updated_at` existed only in route tests. It is now on all
eight published endpoints that accept a `baseUpdatedAt` body token, and the
field description says the thing implementers keep getting wrong: an
unparseable token, `null` included, is rejected with 422 rather than falling
back to the unconditional write. Omitting the key is the only way to get
that write.

/api/auth/me/health-score-config also returns this 422 but is not in the
spec at all; publishing that route is a separate piece of work.
This commit is contained in:
Marc-André Bombeck committed 2026-08-01 11:49:40 +02:00
1 parent 43c591fe19
commit 6a69bad05e
9 files changed
+317 -83

No files matched your search

@@ -10,6 +10,14 @@
*
* The write half (the stamp on a verified assertion) is pinned in
* `src/lib/auth/__tests__/passkey.test.ts`.
*
* v1.35.1 adds the published half. The route was absent from
* `docs/api/openapi.yaml` entirely — no path entry, no schema — while iOS
* had been told to generate against one (iOS #60). The last case below
* compares the projection the route actually asks Prisma for against the
* property set the contract publishes, so the two cannot drift: a column
* added to the select without a schema entry fails, and so does a schema
* property the route never serves.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
@@ -29,6 +37,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { GET } from "../route";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
const USER = { id: "user-1", username: "u", email: "u@example.com" };
@@ -81,6 +90,29 @@ describe("GET /api/auth/passkeys", () => {
expect(Object.keys(body.data[0])).toContain("lastUsedAt");
expect(body.data[0].lastUsedAt).toBeNull();
});
it("publishes exactly the columns it selects", async () => {
await (GET as unknown as () => Promise<Response>)();
const args = vi.mocked(prisma.passkey.findMany).mock.calls[0][0];
const selected = Object.keys(args?.select ?? {}).sort();
const doc = buildOpenApiDocument() as {
paths?: Record<string, { get?: { responses?: Record<string, unknown> } }>;
components?: {
schemas?: Record<string, { properties?: Record<string, unknown> }>;
};
};
expect(
doc.paths?.["/api/auth/passkeys"]?.get,
"GET /api/auth/passkeys is not registered in the OpenAPI document",
).toBeDefined();
const published = doc.components?.schemas?.["PasskeyInfo"]?.properties;
expect(published, "PasskeyInfo schema is not emitted").toBeDefined();
expect(Object.keys(published ?? {}).sort()).toEqual(selected);
});
});
describe("primary-passkey enrollment route guards", () => {
+53
View File
@@ -230,6 +230,40 @@ const webauthnCredentialInfo = z
})
.meta({ id: "WebauthnMfaCredentialInfo" });
// ── Passkey list (primary sign-in credentials) ───────────────────────
// A DIFFERENT surface from `WebauthnMfaCredentialInfo` above: these are
// the credentials a passkey LOGIN asserts against, not the security keys
// registered as a second factor. The two lists are served by different
// routes and are never merged.
const passkeyInfo = z
.object({
id: z.string().describe("Row id; the handle for DELETE."),
name: z.string().describe("User-supplied label, defaulting to “Passkey”."),
credentialDeviceType: z
.string()
.describe(
"Authenticator device type as reported at registration: `singleDevice` for a credential bound to one authenticator, `multiDevice` for a syncable one.",
),
credentialBackedUp: z
.boolean()
.describe(
"Whether the authenticator reported the credential as backed up (synced to the platform keychain) at registration time. Not re-read afterwards.",
),
createdAt: z.iso.datetime({ offset: true }).describe("Registered at."),
lastUsedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"When this passkey last completed a verified assertion; null when it has never been used to sign in. Stamped on every successful login, so a client can render a real 'last used' rather than assuming never.",
),
})
.meta({ id: "PasskeyInfo" });
const passkeyListResponse = z
.array(passkeyInfo)
.meta({ id: "PasskeyListResponse" });
const mfaStatusResponse = z
.object({
totp: z.object({ enabled: z.boolean() }),
@@ -922,6 +956,25 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/passkeys": {
get: {
tags: ["Auth"],
summary: "List the caller's registered passkeys",
description:
"The passkeys registered for primary sign-in, newest first. Distinct from the second-factor security keys in `GET /api/auth/2fa/status` (`webauthn`) — a passkey replaces the password, a security key is asserted after one. `lastUsedAt` is stamped on every verified assertion and is null only for a passkey that has never signed in.",
responses: {
"200": {
description: "The caller's passkeys, newest first.",
content: {
"application/json": {
schema: dataEnvelope(passkeyListResponse, "PasskeyListEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/sessions": {
get: {
tags: ["Auth"],
+5 -1
View File
@@ -47,6 +47,7 @@ import {
stdResponses,
baseUpdatedAtField,
conflictResponse409,
invalidBaseTokenResponse,
} from "./shared";
// ── Coach cadence suggestions (v1.18.1) ──────────────────────────────
@@ -1011,8 +1012,10 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...conflictResponse409("Insights layout", "insights_layout_conflict"),
// 422 (multi-issue validation envelope) comes from stdResponses.
// 422 (multi-issue validation envelope) comes from stdResponses;
// the base-token variant of it narrows that description afterwards.
...stdResponses,
...invalidBaseTokenResponse,
},
},
delete: {
@@ -1233,6 +1236,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...conflictResponse409("Self-context", "about_me_conflict"),
...stdResponses,
...invalidBaseTokenResponse,
},
},
},
+2
View File
@@ -29,6 +29,7 @@ import {
stdResponses,
baseUpdatedAtField,
conflictResponse409,
invalidBaseTokenResponse,
} from "./shared";
const dashboardWidgetConfig = z.object({
@@ -143,6 +144,7 @@ export const dashboardWidgetPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...conflictResponse409("Dashboard layout", "dashboard_layout_conflict"),
...stdResponses,
...invalidBaseTokenResponse,
},
},
delete: {
@@ -27,6 +27,7 @@ import {
errorEnvelope,
stdResponses,
conflictResponse409,
invalidBaseTokenResponse,
} from "../shared";
efficacyTargetOverrideSchema.meta({
@@ -289,6 +290,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"medication_layout_conflict",
),
...stdResponses,
...invalidBaseTokenResponse,
},
},
delete: {
+2
View File
@@ -31,6 +31,7 @@ import {
baseUpdatedAtField,
updatedAtTokenField,
conflictResponse409,
invalidBaseTokenResponse,
} from "./shared";
// ── Request schemas — annotated for spec emission ────────────────────
@@ -566,6 +567,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...conflictResponse409("Mood-tag layout", "mood_tag_layout_conflict"),
...stdResponses,
...invalidBaseTokenResponse,
},
},
},
+4
View File
@@ -20,6 +20,7 @@ import {
baseUpdatedAtField,
updatedAtTokenField,
conflictResponse409,
invalidBaseTokenResponse,
} from "./shared";
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
@@ -499,6 +500,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...conflictResponse409("Coach preferences", "coach_prefs_conflict"),
...stdResponses,
...invalidBaseTokenResponse,
},
},
},
@@ -634,6 +636,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...conflictResponse409("Module preferences", "modules_conflict"),
...stdResponses,
...invalidBaseTokenResponse,
},
},
},
@@ -875,6 +878,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"notification_prefs_conflict",
),
...stdResponses,
...invalidBaseTokenResponse,
},
},
},
+23 -1
View File
@@ -109,7 +109,7 @@ export const baseUpdatedAtField = z.iso
.datetime({ offset: true })
.optional()
.describe(
"Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it.",
"Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it.",
);
/**
@@ -138,6 +138,28 @@ export function conflictResponse409(resource: string, errorCode: string) {
};
}
/**
* The 422 a malformed `baseUpdatedAt` earns, for the write endpoints that
* accept the token in their body.
*
* Spread AFTER `...stdResponses` — the generic 422 there would otherwise
* overwrite this one and the errorCode would vanish from the contract.
*
* Worth stating outright because the shape surprises implementers: an
* unparseable token is NOT silently downgraded to the unconditional write.
* `null`, an empty string and a non-ISO string all 422. The unconditional
* write is reached by OMITTING the key, nothing else. `invalid_base_updated_at`
* had lived only in route tests since v1.32.21, so a client had no way to
* learn this from the published spec.
*/
export const invalidBaseTokenResponse = {
"422": {
description:
"Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it.",
content: { "application/json": { schema: errorEnvelope } },
},
};
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
export const stdResponses = {