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", () => {