diff --git a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts index 6e0d57680..17f9e74a3 100644 --- a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts +++ b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts @@ -148,7 +148,15 @@ describe("PATCH /api/auth/me/unit-preference", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); - it("rejects malformed JSON with 422", async () => { + // The three cases below pin the capped `safeJson` read. Before it, the + // handler called `request.json()` bare: malformed JSON came back 422 rather + // than the 400 every sibling scalar answers, a wrong content type was parsed + // rather than refused, and there was NO body-size limit — this was the one + // endpoint in the family that would materialise whatever it was handed. The + // malformed-JSON assertion used to say 422 and was changed deliberately: it + // pinned the inconsistency rather than a contract, and the only caller + // branches on `res.ok` alone. + it("rejects malformed JSON with 400, like its sibling scalars", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/unit-preference", { method: "PATCH", @@ -156,7 +164,36 @@ describe("PATCH /api/auth/me/unit-preference", () => { headers: { "Content-Type": "application/json" }, }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses a body over the 1 KB cap with 413", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Valid JSON, valid enum value, and far too big: the cap has to be what + // rejects it, not the schema. + const req = new Request("http://localhost/api/auth/me/unit-preference", { + method: "PATCH", + body: JSON.stringify({ + unitPreference: "imperial", + padding: "x".repeat(2048), + }), + headers: { "Content-Type": "application/json" }, + }); + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(413); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses a non-JSON content type with 415", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const req = new Request("http://localhost/api/auth/me/unit-preference", { + method: "PATCH", + body: "unitPreference=imperial", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(415); expect(prisma.user.update).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/auth/me/unit-preference/route.ts b/src/app/api/auth/me/unit-preference/route.ts index e101378b8..ae7b459d7 100644 --- a/src/app/api/auth/me/unit-preference/route.ts +++ b/src/app/api/auth/me/unit-preference/route.ts @@ -15,13 +15,22 @@ * Mirrors the `disable-coach` per-user-scalar pattern: 60/min rate * limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row, * field-by-field write (no mass assignment). + * + * The body goes through `safeJson` with a 1 KB cap, like the + * `documents-auto-ai-read` scalar beside it. It used to read a bare + * `request.json()`, which applied no size limit at all — the one endpoint in + * this family that would materialise whatever it was sent — and answered a + * malformed body with 422 where every sibling answers 400. Both were + * accidents of the original copy, not a contract: the only caller checks + * `res.ok` and never the status. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp, returnAllZodIssues, + safeJson, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -73,12 +82,12 @@ export const PATCH = apiHandler(async (req: Request) => { return response; } - let body: unknown; - try { - body = await req.json(); - } catch { - throw new HttpError(422, "unit-preference.body.invalid_json"); - } + // The body is a single enum value — bound the parse so a malformed or + // oversized payload is rejected before it is materialised. + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; const parsed = unitPreferencePatchSchema.safeParse(body); if (!parsed.success) { diff --git a/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts b/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..ee81a5d11 --- /dev/null +++ b/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts @@ -0,0 +1,157 @@ +/** + * `PATCH /api/auth/passkeys/{id}` — the rename's refusal has to name the field. + * + * The handler used to answer `apiError("Invalid request", 422)` and throw the + * Zod issues away, which is the one thing every sibling body-parsing route in + * this tree does not do. A person renaming a passkey to an empty string and a + * person pasting 200 characters got the same three words, so the form had + * nothing to put beside the field and no way to say which rule was broken. + * + * These cases pin the multi-issue envelope by its CONTENT — the issue list has + * to be present and has to point at `name` — rather than by status alone. A + * status-only assertion would have passed against the flat error it replaced. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, + requireAuth: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + passkey: { + findUnique: vi.fn(), + update: vi.fn(), + count: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +import { PATCH } from "../route"; +import { requireAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; + +const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" }; + +const ROW = { + id: "pk-1", + name: "Phone", + credentialDeviceType: "multiDevice", + credentialBackedUp: true, + createdAt: new Date("2026-01-01T00:00:00Z"), + lastUsedAt: null, +}; + +type IssueEnvelope = { + error: string; + details?: { issues?: Array<{ path?: unknown; message?: string }> }; +}; + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/passkeys/pk-1", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +const params = Promise.resolve({ id: "pk-1" }); + +type Handler = ( + r: Request, + ctx: { params: Promise<{ id: string }> }, +) => Promise; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never); +}); + +describe("PATCH /api/auth/passkeys/{id} — the rename refusal", () => { + it("returns the multi-issue envelope for an empty name", async () => { + const res = await (PATCH as unknown as Handler)(mkPatch({ name: "" }), { + params, + }); + + expect(res.status).toBe(422); + const env = (await res.json()) as IssueEnvelope; + // The flat refusal this replaces said "Invalid request" and carried no + // `details` at all, so both halves matter. + expect(env.error).toBe("Validation failed"); + expect(env.details?.issues?.length).toBeGreaterThan(0); + expect(JSON.stringify(env.details?.issues)).toContain("name"); + expect(prisma.passkey.update).not.toHaveBeenCalled(); + }); + + it("returns the multi-issue envelope for a name over 64 characters", async () => { + const res = await (PATCH as unknown as Handler)( + mkPatch({ name: "x".repeat(65) }), + { params }, + ); + + expect(res.status).toBe(422); + const env = (await res.json()) as IssueEnvelope; + expect(env.details?.issues?.length).toBeGreaterThan(0); + expect(JSON.stringify(env.details?.issues)).toContain("name"); + expect(prisma.passkey.update).not.toHaveBeenCalled(); + }); + + it("distinguishes the two refusals from each other", async () => { + // The point of the change: a client can tell WHICH rule was broken. Under + // the flat error both bodies were byte-identical. + const tooShort = await (PATCH as unknown as Handler)( + mkPatch({ name: "" }), + { params }, + ); + const tooLong = await (PATCH as unknown as Handler)( + mkPatch({ name: "x".repeat(65) }), + { params }, + ); + + expect(JSON.stringify(await tooShort.json())).not.toBe( + JSON.stringify(await tooLong.json()), + ); + }); + + it("still renames on a valid name, trimming it first", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + userId: USER.id, + } as never); + vi.mocked(prisma.passkey.update).mockResolvedValue({ + ...ROW, + name: "Work laptop", + } as never); + + const res = await (PATCH as unknown as Handler)( + mkPatch({ name: " Work laptop " }), + { params }, + ); + + expect(res.status).toBe(200); + expect(prisma.passkey.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { name: "Work laptop" } }), + ); + }); + + it("does not leak another account's passkey through the rename", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + userId: "someone-else", + } as never); + + const res = await (PATCH as unknown as Handler)( + mkPatch({ name: "Mine now" }), + { params }, + ); + + expect(res.status).toBe(404); + expect(prisma.passkey.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index eb95c324b..5c7ad7788 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -4,6 +4,7 @@ import { apiSuccess, apiError, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { NextRequest } from "next/server"; @@ -26,7 +27,12 @@ export const PATCH = apiHandler( const parsed = passkeyRenameSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + // Multi-issue, like every sibling that parses a body. The flat + // `apiError("Invalid request", 422)` this replaces discarded the issues, + // so a person renaming a passkey could not tell an empty name from one + // over the 64-character limit — the form had nothing to put beside the + // field. + return returnAllZodIssues(parsed.error, 422); } const passkey = await prisma.passkey.findUnique({ diff --git a/src/app/api/tokens/[id]/route.ts b/src/app/api/tokens/[id]/route.ts index ef19a4348..c6659d20d 100644 --- a/src/app/api/tokens/[id]/route.ts +++ b/src/app/api/tokens/[id]/route.ts @@ -8,6 +8,11 @@ type RouteParams = { params: Promise<{ id: string }> }; /** * Revoke an API token. + * + * Still gated on the instance-wide API switch, where the sibling LIST is not. + * The reasoning for the split lives in `../route.ts`: the list answers which + * credentials exist on the caller's own account and is never the unsafe half, + * while this one mutates the token surface the operator has switched off. */ export const DELETE = apiHandler( async (_request: Request, { params }: RouteParams) => { diff --git a/src/app/api/tokens/__tests__/route.test.ts b/src/app/api/tokens/__tests__/route.test.ts new file mode 100644 index 000000000..2c757c67d --- /dev/null +++ b/src/app/api/tokens/__tests__/route.test.ts @@ -0,0 +1,180 @@ +/** + * `/api/tokens` — the instance-wide API switch must not hide live credentials. + * + * `AppSettings.apiGlobal` gates the surfaces a token is FOR — external + * medication ingest, the MCP bridge — and not a token's ability to + * authenticate: nothing on the `requireAuth` path consults it, which the last + * case here asserts directly rather than assuming. So a token minted before an + * operator flipped the switch stays a live credential while it is off, and the + * LIST used to answer 403 — hiding exactly the credentials their owner most + * needed to see, including the wildcard access token their own phone signs in + * with. + * + * The read is ungated now. The revoke is not, and that asymmetry is pinned + * below rather than left to drift: it is a decision about what the switch + * means, and a change to it should be a visible diff here. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, + requireAuth: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn() })); + +import { GET } from "../route"; +import { DELETE } from "../[id]/route"; +import { requireAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; + +const USER = { id: "user-1", username: "u" }; + +const ROW = { + id: "tok-1", + name: "iPhone", + permissions: ["*"], + lastUsedAt: new Date("2026-08-01T10:00:00Z"), + expiresAt: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + revoked: false, +}; + +type ListEnvelope = { + data: Array<{ id: string }> | null; + error: string | null; +}; + +const params = Promise.resolve({ id: "tok-1" }); + +type DeleteHandler = ( + r: Request, + ctx: { params: Promise<{ id: string }> }, +) => Promise; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never); + vi.mocked(prisma.apiToken.findMany).mockResolvedValue([ROW] as never); +}); + +describe("GET /api/tokens", () => { + it("lists the caller's tokens while the operator has the API switched off", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + + const res = await (GET as unknown as () => Promise)(); + + expect(res.status).toBe(200); + const env = (await res.json()) as ListEnvelope; + expect(env.error).toBeNull(); + expect(env.data?.[0]?.id).toBe("tok-1"); + expect(prisma.apiToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: USER.id } }), + ); + }); + + it("does not consult the switch at all", async () => { + // Stronger than the status assertion above and the reason it is separate: + // a future re-gating that happened to answer 200 anyway would still be a + // read whose behaviour depends on an operator toggle it has no business + // reading. + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + + await (GET as unknown as () => Promise)(); + + expect(isApiGloballyEnabled).not.toHaveBeenCalled(); + }); + + it("still scopes the list to the caller", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + + await (GET as unknown as () => Promise)(); + + const args = vi.mocked(prisma.apiToken.findMany).mock.calls[0][0]; + expect(args?.where).toEqual({ userId: USER.id }); + // No `revoked: false` filter — revoked rows stay in the list, which is + // what the published contract says and what the UI relies on. + expect(JSON.stringify(args?.where)).not.toContain("revoked"); + }); +}); + +describe("DELETE /api/tokens/{id}", () => { + it("keeps refusing while the operator has the API switched off", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + + const res = await (DELETE as unknown as DeleteHandler)( + new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), + { params }, + ); + + expect(res.status).toBe(403); + expect(prisma.apiToken.update).not.toHaveBeenCalled(); + }); + + it("revokes when the switch is on", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + ...ROW, + userId: USER.id, + } as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + + const res = await (DELETE as unknown as DeleteHandler)( + new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), + { params }, + ); + + expect(res.status).toBe(200); + expect(prisma.apiToken.update).toHaveBeenCalledWith({ + where: { id: "tok-1" }, + data: { revoked: true }, + }); + }); + + it("does not reach another account's token", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + ...ROW, + userId: "someone-else", + } as never); + + const res = await (DELETE as unknown as DeleteHandler)( + new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), + { params }, + ); + + expect(res.status).toBe(404); + expect(prisma.apiToken.update).not.toHaveBeenCalled(); + }); +}); + +describe("the switch and the authentication path", () => { + it("is not consulted by requireAuth, which is why the read must not gate on it", async () => { + // The premise the fix rests on, asserted rather than assumed: if the auth + // kit ever started refusing Bearer callers while `apiGlobal` is off, then + // a token would NOT be live during the outage and hiding the list would + // stop being wrong. This reads the source because the behaviour is an + // absence, and an absence has no call to spy on. + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const source = readFileSync( + join(process.cwd(), "src/lib/api-handler.ts"), + "utf8", + ); + expect(source).not.toContain("isApiGloballyEnabled"); + expect(source).not.toContain("apiGlobal"); + }); +}); diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index 541e0bf02..0a653efb0 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -11,21 +11,31 @@ * (`/api/medications/[id]/api-endpoint`), which issues both grants. * * Listing and revoking stay so existing tokens remain visible and revocable. + * + * The list is NOT gated on the instance-wide API switch, and the revoke is. + * The asymmetry is deliberate, and rests on what the switch actually does: + * `AppSettings.apiGlobal` gates the surfaces a token is FOR — external + * medication ingest, the MCP bridge — and not the token's ability to + * authenticate. Nothing on the `requireAuth` path consults it. So a token + * minted before an operator flipped the switch is still a live credential + * while it is off, and refusing the list hid exactly the credentials their + * owner most needed to see. Answering "which credentials exist on your own + * account" is never the unsafe half of this surface: it discloses no token + * material, and it is how a person finds the row to kill. + * + * The revoke keeps the gate as the surface stands. Whether the switch should + * also stop an owner killing a live token is a question about what the switch + * means, not a property of the read, so it is left where it was. */ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; -import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { apiSuccess } from "@/lib/api-response"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "tokens.list" } }); - if (!(await isApiGloballyEnabled())) { - return apiError("API is globally disabled", 403); - } - const tokens = await prisma.apiToken.findMany({ where: { userId: user.id }, select: { diff --git a/src/app/api/user/thresholds/__tests__/route.test.ts b/src/app/api/user/thresholds/__tests__/route.test.ts index fd37799d0..dca481f73 100644 --- a/src/app/api/user/thresholds/__tests__/route.test.ts +++ b/src/app/api/user/thresholds/__tests__/route.test.ts @@ -44,9 +44,11 @@ vi.mock("next/headers", () => ({ })), })); -import { PUT } from "../route"; +import { GET, PUT } from "../route"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { getAllEffectiveRanges } from "@/lib/analytics/effective-range"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -108,3 +110,73 @@ describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => { expect(body.details.issues.length).toBeGreaterThanOrEqual(3); }); }); + +/** + * The docblock promised a key the handler has never returned. + * + * For as long as this route has existed its header said GET answers + * `{ defaults, overrides, effective }`. It answers `{ effective, overrides }`; + * the per-metric defaults live inside `effective[metric].default`. Anyone + * reading the file to find out what to decode was told to expect a top-level + * key that is not there. + * + * Asserting the shape alone would not have caught it — the code was always + * right. So this compares the two: the keys the response actually carries + * against the keys the docblock advertises. A comment that drifts from the + * handler fails here, in either direction. + */ +describe("GET /api/user/thresholds — the docblock describes the response", () => { + const RESPONSE_KEYS = ["effective", "overrides"]; + + it("returns exactly the documented top-level keys", async () => { + // `beforeEach` calls `vi.resetAllMocks()`, which clears the module-level + // `mockReturnValue({})` — without re-arming it the resolver returns + // undefined and `effective` vanishes from the JSON entirely. + vi.mocked(getAllEffectiveRanges).mockReturnValue({ + WEIGHT: { + range: { greenMin: 60, greenMax: 80, orangeMin: 57, orangeMax: 83 }, + isOverride: false, + default: { greenMin: 60, greenMax: 80, orangeMin: 57, orangeMax: 83 }, + bounds: { min: 30, max: 300, unit: "kg" }, + }, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: 180, + dateOfBirth: null, + gender: null, + thresholdsJson: null, + } as never); + + const res = await (GET as unknown as () => Promise)(); + expect(res.status).toBe(200); + + const body = (await res.json()) as { data: Record }; + expect(Object.keys(body.data).sort()).toEqual([...RESPONSE_KEYS].sort()); + expect(body.data).not.toHaveProperty("defaults"); + }); + + it("names those same keys in the route's own header comment", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const source = readFileSync( + join(process.cwd(), "src/app/api/user/thresholds/route.ts"), + "utf8", + ); + const docblock = source.slice(0, source.indexOf("*/")); + + // The promise the header makes, as a brace list: `{ effective, overrides }`. + const promised = docblock.match(/GET returns `\{([^}]+)\}`/); + expect( + promised, + "the header no longer states what GET returns", + ).not.toBeNull(); + + const promisedKeys = (promised?.[1] ?? "") + .split(",") + .map((key) => key.trim()) + .filter(Boolean) + .sort(); + + expect(promisedKeys).toEqual([...RESPONSE_KEYS].sort()); + }); +}); diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts index c11ad5410..5034ae027 100644 --- a/src/app/api/user/thresholds/route.ts +++ b/src/app/api/user/thresholds/route.ts @@ -1,8 +1,12 @@ /** * GET / PUT / DELETE per-user threshold overrides. * - * GET returns `{ defaults, overrides, effective }` so the settings UI can - * show a side-by-side comparison. + * GET returns `{ effective, overrides }`. The side-by-side comparison the + * settings UI draws comes out of `effective`, not out of a sibling key: each + * entry carries `range` (the band in force), `isOverride`, `default` (the + * unmodified computed band) and `bounds`. There is no top-level `defaults` + * key and there never was — this comment promised one for long enough that + * it is worth stating the absence rather than just deleting the word. * PUT accepts a partial map — only the provided metrics are updated, others * keep their existing override or remain on the default. * DELETE with ?metric=... resets one metric to default. diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index e804bc6a6..19f313aa1 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -1316,7 +1316,7 @@ export const authPaths: NonNullable = { summary: "Rename a registered passkey", description: "Relabels one primary sign-in credential, scoped to the authenticated user. Returns the full passkey row, so the client can replace its list entry rather than re-read.\n\n" + - 'Unlike the rest of this surface the 422 carries a single flat message rather than the multi-issue envelope `returnAllZodIssues` produces — the handler answers `apiError("Invalid request", 422)` and discards the Zod issues, so a client cannot tell an empty name from an over-long one.', + "The name is trimmed before validation and must be 1 to 64 characters once trimmed. A rejected name earns the standard multi-issue 422, so a form can put the reason beside the field rather than showing a bare “invalid request”.", requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, @@ -1376,7 +1376,7 @@ export const authPaths: NonNullable = { summary: "List the caller's API tokens", description: "Every `ApiToken` row belonging to the caller, newest first, revoked ones included — presence in this list is not validity, `revoked` is. The hash is never returned and there is no path that re-reveals a token's plaintext.\n\n" + - 'Two things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. And there is no mint on this path any more — the generic POST that issued `["medication:ingest"]` was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The working credential comes from the per-medication API-endpoint toggle.', + "Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no mint on this path any more — the generic POST that issued `[\"medication:ingest\"]` was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else; the working credential comes from the per-medication API-endpoint toggle. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.", responses: { "200": { description: "The caller's tokens, newest first.", @@ -1389,11 +1389,6 @@ export const authPaths: NonNullable = { }, }, }, - "403": { - description: - "The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). Checked after authentication, carries no `meta.errorCode`, and applies to the read as well as the revoke — a user cannot inspect their own tokens while the switch is off.", - content: { "application/json": { schema: errorEnvelope } }, - }, ...stdResponses, }, }, @@ -1420,7 +1415,7 @@ export const authPaths: NonNullable = { }, "403": { description: - "The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing was revoked.", + "The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing was revoked. The sibling list is deliberately not gated this way — a token stays live while the switch is off, so its owner can still see it here even though this call will not kill it.", content: { "application/json": { schema: errorEnvelope } }, }, "404": { diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index ee60970f9..628e23592 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -1223,7 +1223,7 @@ export const profilePaths: NonNullable = { summary: "Set the metric/imperial display preference", description: "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" + - "The only endpoint in this group that reads its body with a bare `request.json()` rather than the capped `safeJson` helper, so it applies no body-size limit and answers a malformed body with 422 where its siblings answer 400. Both are the code as it stands.", + "The body is capped at 1 KB and must carry `Content-Type: application/json`. Anything larger is 413, a wrong content type is 415, and a body that is not JSON at all is 400 — the same three the other scalars in this group answer.", requestBody: { required: true, content: { @@ -1242,6 +1242,10 @@ export const profilePaths: NonNullable = { }, }, }, + "413": { + description: "Request body exceeds 1024 bytes. Nothing was written.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, },