Stop the API switch hiding live tokens, and cap the unit-preference body

Four defects the registry work surfaced, each with a check that fails without
the fix.

`GET /api/tokens` no longer consults the instance-wide API switch. That switch
gates the surfaces a token is FOR — external ingest, the MCP bridge — and not a
token's ability to authenticate: nothing on the `requireAuth` path reads it, so
a token minted before an operator flipped it stays live while it is off, and
refusing the list hid exactly the credentials their owner most needed to see,
their own signed-in phone among them. The revoke keeps the gate; the asymmetry
is stated in both route comments and pinned by the test, because widening it is
a decision about what the switch means rather than a fix to the read.

`PATCH /api/auth/me/unit-preference` reads its body through `safeJson` with a
1 KB cap, like the scalar beside it. It used a bare `request.json()` and was the
one endpoint in the family with no body-size limit at all — the new test proves
it by sending 2 KB and watching the old handler answer 200. Malformed JSON now
answers 400 like every sibling instead of 422; the only caller checks `res.ok`
and never the status, so nothing depends on the old code. The existing
malformed-JSON assertion was changed deliberately: it pinned the inconsistency,
not a contract.

`PATCH /api/auth/passkeys/{id}` returns the multi-issue envelope. It answered a
flat "Invalid request", so an empty name and a 200-character name produced
byte-identical bodies and the form had nothing to show beside the field.

`GET /api/user/thresholds` has a header that describes the handler. It promised
`{ defaults, overrides, effective }` and has always returned
`{ effective, overrides }`. The guard compares the two, so the comment cannot
drift again in either direction.

The two published operations whose contracts moved are updated with them.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 12:39:43 +02:00
1 parent b6efc6cb5a
commit 63f791120a
11 files changed
+507 -28

No files matched your search

@@ -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<Response>)(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<Response>)(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<Response>)(req);
expect(res.status).toBe(415);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+16 -7
View File
@@ -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) {
@@ -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<Response>;
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();
});
});
+7 -1
View File
@@ -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({
+5
View File
@@ -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) => {
+180
View File
@@ -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<Response>;
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<Response>)();
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<Response>)();
expect(isApiGloballyEnabled).not.toHaveBeenCalled();
});
it("still scopes the list to the caller", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
await (GET as unknown as () => Promise<Response>)();
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");
});
});
+16 -6
View File
@@ -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: {
@@ -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<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
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());
});
});
+6 -2
View File
@@ -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.
+3 -8
View File
@@ -1316,7 +1316,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
},
},
},
"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<ZodOpenApiObject["paths"]> = {
},
"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": {
+5 -1
View File
@@ -1223,7 +1223,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},