Refuse what these four routes used to absorb in silence

Four surfaces answered a bad request by quietly substituting a good one. Each
fix comes with the test that catches it, and each test was confirmed red
against the old behaviour before the fix went back in.

`PATCH /api/user/ai-provider` inspected its body key by key with `typeof`
guards, so a numeric `model` or a boolean `baseUrl` failed every guard,
produced no entry in the update, and the write went ahead without it — a 200
that says "saved" for a field that was discarded. A body of nothing but such
keys came back as "No valid fields" naming none of them. The real schema now
lives in `src/lib/validations/ai-provider.ts`, the route runs one `safeParse`,
and a mismatch is the multi-issue 422 every sibling answers with. The accepted
field set is unchanged, and the object is deliberately not strict: an unknown
key has always been ignored here and still is. The one thing the schema has to
carry that no other does is that this body holds plaintext credentials, so no
rule may interpolate the value it rejected — noted where the next `.refine()`
would go.

`?slice=` on `/api/analytics` was compared against one literal and fell
through to the DEFAULT body on anything else, so `?slice=summary` answered 200
after running the heaviest chain on the surface. Invisible and expensive at
once. Closed enum, 422 on anything outside it.

`?metricType=` and `?limit=` on `/api/personal-records` were parsed
defensively, which read as robustness and behaved as silence — and in one
direction it was worse than silence: dropping an unrecognised `metricType`
WIDENS the read to every metric, so a typo returned more of the record than
was asked for. Both refuse now, and the 500 ceiling refuses rather than
clamps, because serving a different number of rows than were requested is the
same quiet substitution. No caller in the tree sends a value the new rules
reject.

`POST /api/ingest/medication` bucketed on the client address, so a household
running a chat bridge, a button and a home-automation rule — the normal
deployment for this route — put all three in one 60/min cell and let the
chattiest starve the rest. It already resolves a per-caller identity two lines
further down and threw it away; the bucket is that token now. A request that
resolves no token keeps the per-IP bucket it always had, through the shared
anonymous helper, so a flood is still capped and a broken proxy chain
collapses into the tight global cell rather than a shared "unknown" one. Its
429 goes through `apiError` and carries `meta` like every other refusal.

Two older tests pinned the defects by name — "drops an unknown metricType
silently" and "falls back to default on garbage ?limit" — and one pinned a
throw where the rest of the surface returns an envelope. All three are
rewritten with the reason beside them.

The generated YAML is deliberately not in this commit.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 13:23:48 +02:00
1 parent 49da15feaa
commit bb9bf0ba68
15 files changed
+682 -255

No files matched your search

+8 -3
View File
@@ -27,7 +27,7 @@ import {
derivedProvenance,
glucoseClinicalSchema,
} from "./insights/schemas";
import { dataEnvelope, stdResponses } from "./shared";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// ── The per-type summary the tile strip renders ──────────────────────
@@ -512,7 +512,7 @@ export const analyticsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Analytics"],
summary: "The dashboard analytics aggregate",
description:
"The web dashboard's aggregate read, in two shapes chosen by `?slice=`.\n\n`?slice=summaries` returns the slim tile-strip body from two SQL passes. ANY other value — including an absent, empty or misspelt one — returns the thick default body; the parameter is matched for the exact string `summaries` and is never rejected, so `?slice=summary` silently gets the expensive path.\n\nBoth shapes are served through a stale-while-revalidate cache. A cached body can be up to a minute old, and the default slice's cache key includes the reader's locale because the correlation `interpretation` sentences are narrated per reader. `Cache-Control: private, max-age=0, must-revalidate` on both — bfcache-friendly rather than the framework's stock `no-store`.\n\nThis GET is not free of side effects, and a caller polling it should know: it records a Health Score row for the scored instant, syncs accepted correlation hypotheses into the pattern ledger (which is what mints the `patternId` the dismissal endpoint takes), and kicks a background rollup-freshness refresh. The refresh is fire-and-forget by design — awaiting it stalled the event loop for tens of seconds on large accounts — so a value can be up to about a minute behind a measurement that just landed, and the next read has it.\n\nAuth is a cookie session or a wildcard (`*`) Bearer token; a narrow-scope token is refused. NOT delegable: it resolves the caller's own record, so a switched session still reads its own analytics.",
"The web dashboard's aggregate read, in two shapes chosen by `?slice=`.\n\n`?slice=summaries` returns the slim tile-strip body from two SQL passes; omitting the parameter returns the thick default body. Any OTHER value is refused with 422 (`meta.errorCode` = `analytics.invalid_query`). It used to fall through to the default instead, which made `?slice=summary` answer 200 after running the heaviest chain on the surface — the typo was invisible and expensive at the same time.\n\nBoth shapes are served through a stale-while-revalidate cache. A cached body can be up to a minute old, and the default slice's cache key includes the reader's locale because the correlation `interpretation` sentences are narrated per reader. `Cache-Control: private, max-age=0, must-revalidate` on both — bfcache-friendly rather than the framework's stock `no-store`.\n\nThis GET is not free of side effects, and a caller polling it should know: it records a Health Score row for the scored instant, syncs accepted correlation hypotheses into the pattern ledger (which is what mints the `patternId` the dismissal endpoint takes), and kicks a background rollup-freshness refresh. The refresh is fire-and-forget by design — awaiting it stalled the event loop for tens of seconds on large accounts — so a value can be up to about a minute behind a measurement that just landed, and the next read has it.\n\nAuth is a cookie session or a wildcard (`*`) Bearer token; a narrow-scope token is refused. NOT delegable: it resolves the caller's own record, so a switched session still reads its own analytics.",
parameters: [
{
name: "slice",
@@ -520,7 +520,7 @@ export const analyticsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
required: false,
schema: { type: "string", enum: ["summaries"] },
description:
"`summaries` selects the slim tile-strip body. Any other value falls through to the default body rather than erroring.",
"`summaries` selects the slim tile-strip body. Omit it for the default body. Any other value is a 422 — it is not treated as an omission.",
},
],
responses: {
@@ -537,6 +537,11 @@ export const analyticsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
"422": {
description:
"`slice` carried a value outside the closed set. `meta.errorCode` = `analytics.invalid_query`, with the offending path under `details.issues`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+9 -3
View File
@@ -19,6 +19,7 @@ import {
import {
MODULE_DISABLED_DESCRIPTION,
dataEnvelope,
errorEnvelope,
recordRefusal,
stdResponses,
} from "./shared";
@@ -158,7 +159,7 @@ export const awardsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Awards"],
summary: "List detected personal bests",
description:
"The record's personal bests, newest achievement first. Read-only — rows come from the detection worker and no endpoint creates or edits one.\n\nBoth query parameters are parsed defensively and NEVER 422: an unrecognised `metricType` is dropped (the response is then unfiltered rather than empty), and a `limit` that is not a positive integer falls back to the default rather than being rejected. A caller cannot tell a typo from a deliberate omission, so validate on your side.\n\nDelegable on a `measurements` grant.",
"The record's personal bests, newest achievement first. Read-only — rows come from the detection worker and no endpoint creates or edits one.\n\nBoth query parameters are validated and a bad value is a 422. They were forgiving until this release, and in one direction that was worse than merely quiet: dropping an unrecognised `metricType` WIDENED the read to every metric, so a typo returned more rows than the caller asked for. The `limit` ceiling refuses rather than clamps for the same reason.\n\nDelegable on a `measurements` grant.",
parameters: [
{
name: "metricType",
@@ -166,7 +167,7 @@ export const awardsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
required: false,
schema: { type: "string" },
description:
"A `MeasurementType` to filter to. A value outside the enum is IGNORED, not refused.",
"A `MeasurementType` to filter to. A value outside the enum is refused with 422 — it is not dropped, because dropping it would widen the read rather than narrow it.",
},
{
name: "limit",
@@ -174,7 +175,7 @@ export const awardsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
required: false,
schema: { type: "integer", minimum: 1, maximum: 500, default: 100 },
description:
"Page cap, default 100, clamped at 500. A non-integer, zero or negative value silently reverts to 100; a value above 500 clamps down.",
"Page cap, default 100, ceiling 500. A non-integer, a zero, a negative value or anything above 500 is refused with 422 rather than corrected — serving a different number of rows than were asked for is another difference nobody is told about.",
},
],
responses: {
@@ -191,6 +192,11 @@ export const awardsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
"422": {
description:
"`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+3 -3
View File
@@ -32,7 +32,7 @@ export const ingestPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Record a dose from an external integration",
description:
"Logs a medication intake from outside the app — a chat bridge, a home-automation rule, a button on a shelf.\n\n**Auth is Bearer only.** A cookie session does not authenticate here: the route reads the `Authorization` header itself, hashes it and looks the token up, so a browser with a valid login and no token gets 401. The token must carry either the `*` wildcard or the `medication:ingest` scope — AND, once the named medication resolves, either `*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup, which is why a token scoped to one medication gets 404 for a name it may not touch only when the name does not exist, and 403 when it does.\n\n**The operator can switch this off.** With the global API kill switch disabled every request is refused with 403 before the token is even read.\n\n**Replay is by `idempotencyKey`, and the status says which happened.** A key already stored for this token's owner returns the existing row with **200**; a fresh dose returns **201**. Nothing else distinguishes them — same body shape, same fields — so branch on the status code.\n\n**A dose is attributed to its scheduled slot, not stamped ad-hoc.** When the take falls inside a schedule's window band it converges onto that slot's canonical row (closing the pending reminder rather than leaving it open beside a duplicate ad-hoc take); otherwise it lands as a standalone row with `scheduledFor = takenAt`. Only a genuine pending-to-taken transition consumes inventory, so a re-post that converges onto an already-taken slot does not decrement stock twice.\n\nRate-limited to 60 requests per minute **per client IP**, not per token — several bridges behind one NAT share the bucket. The limit is applied before the token is read, so an unauthenticated flood is capped too.",
"Logs a medication intake from outside the app — a chat bridge, a home-automation rule, a button on a shelf.\n\n**Auth is Bearer only.** A cookie session does not authenticate here: the route reads the `Authorization` header itself, hashes it and looks the token up, so a browser with a valid login and no token gets 401. The token must carry either the `*` wildcard or the `medication:ingest` scope — AND, once the named medication resolves, either `*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup, which is why a token scoped to one medication gets 404 for a name it may not touch only when the name does not exist, and 403 when it does.\n\n**The operator can switch this off.** With the global API kill switch disabled every request is refused with 403 before the token is even read.\n\n**Replay is by `idempotencyKey`, and the status says which happened.** A key already stored for this token's owner returns the existing row with **200**; a fresh dose returns **201**. Nothing else distinguishes them — same body shape, same fields — so branch on the status code.\n\n**A dose is attributed to its scheduled slot, not stamped ad-hoc.** When the take falls inside a schedule's window band it converges onto that slot's canonical row (closing the pending reminder rather than leaving it open beside a duplicate ad-hoc take); otherwise it lands as a standalone row with `scheduledFor = takenAt`. Only a genuine pending-to-taken transition consumes inventory, so a re-post that converges onto an already-taken slot does not decrement stock twice.\n\nRate-limited to 60 requests per minute **per token**. A household running a chat bridge, a button and a home-automation rule gets three independent allowances rather than three shares of one, which is what the previous per-IP bucket gave them. A request that presents no valid token falls back to a per-IP bucket with the same ceiling, so an unauthenticated flood is still capped.",
requestBody: {
required: true,
content: { "application/json": { schema: externalIntakeRequest } },
@@ -64,7 +64,7 @@ export const ingestPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"401": {
description:
"No `Authorization: Bearer …` header, or a token that is unknown, revoked or past its expiry. The three cases are not distinguished on the wire.",
"No `Authorization: Bearer …` header, or a token that is unknown, revoked or past its expiry. The three cases are not distinguished on the wire, and all four are charged to the per-IP bucket rather than to any token.",
content: { "application/json": { schema: errorEnvelope } },
},
"403": {
@@ -92,7 +92,7 @@ export const ingestPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"429": {
description:
"More than 60 requests in a minute from this client IP. Carries the standard rate-limit headers. Note the body is the bare `{ data: null, error }` pair without `meta`.",
"More than 60 requests in a minute for this token — or, when no valid token was presented, from this client IP. `meta.errorCode` = `ingest.rate_limited`, plus the standard `X-RateLimit-Remaining` / `X-RateLimit-Reset` headers.",
content: { "application/json": { schema: errorEnvelope } },
},
},
+12 -78
View File
@@ -15,7 +15,7 @@ import { MODULE_KEYS } from "@/lib/modules/registry";
import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types";
import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape";
import {
AI_PROVIDER_KINDS,
aiProviderPatchSchema,
aiTestOverrideSchema,
} from "@/lib/validations/ai-provider";
import {
@@ -518,81 +518,15 @@ const aiProviderResponse = z
"The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key.",
});
// The PATCH body is documented here rather than imported from a validations
// module because there is nothing to import: the route hand-rolls its parse
// field by field instead of running a Zod `safeParse`, so the shape below is
// read off that code. Two consequences a client needs to be told, because no
// generated schema would carry them: a key of the wrong TYPE (a number
// `model`, a boolean `baseUrl`) is silently ignored rather than refused, and
// a body containing only such keys fails as "No valid fields" rather than
// naming the offender.
const aiProviderPatchRequest = z
.object({
provider: z
.enum(AI_PROVIDER_KINDS)
.nullable()
.optional()
.describe(
'The provider to select. `null` or `""` clears it. Any other unrecognised value is a 422 — this is the one field whose bad value is refused rather than skipped.',
),
model: z
.string()
.nullable()
.optional()
.describe('Trimmed; `null`, `""` or whitespace-only clears it.'),
baseUrl: z
.string()
.nullable()
.optional()
.describe(
'Custom base URL for the LOCAL provider. `null` or `""` clears it. A non-empty value runs the SSRF floor: a private or internal host is refused with 422 unless the operator allowlisted that exact host. NOTE this column is shared — switching `provider` away from LOCAL in the same request clears it automatically, so a stored cloud key can never be sent to a host configured for something else.',
),
compatBaseUrl: z
.string()
.nullable()
.optional()
.describe(
"Base URL of the OpenAI-compatible gateway. Its own column, deliberately not shared with `baseUrl`. Same SSRF floor.",
),
compatModel: z.string().nullable().optional(),
compatKey: z
.string()
.nullable()
.optional()
.describe(
'Bearer for the gateway; encrypted at rest. `null` or `""` clears it. Never echoed back — the read reports presence only.',
),
anthropicKey: z
.string()
.nullable()
.optional()
.describe('Encrypted at rest. `null` or `""` clears it.'),
localKey: z
.string()
.nullable()
.optional()
.describe('Encrypted at rest. `null` or `""` clears it.'),
openaiKey: z
.string()
.nullable()
.optional()
.describe('Encrypted at rest. `null` or `""` clears it.'),
responseTimeoutSeconds: z
.number()
.int()
.min(10)
.max(600)
.nullable()
.optional()
.describe(
"Per-user AI response timeout in seconds, 10–600. `null` restores the built-in default (~120 s). A number outside the range, or a non-integer, is a 422.",
),
})
.meta({
id: "AiProviderPatchRequest",
description:
"Partial update of the calling user's AI-provider configuration. An omitted key is untouched; an explicit `null` (or empty string, on the string fields) clears the column. At least one recognised key must be present. Key material is write-only: it is encrypted on the way in and never returned.",
});
// The PATCH body is the runtime validator, imported rather than restated.
// It used to be described here instead, because the route hand-rolled its
// parse field by field and there was nothing to import; the route runs
// `safeParse` now, so the published shape and the parse are one object.
const aiProviderPatchRequest = aiProviderPatchSchema.meta({
id: "AiProviderPatchRequest",
description:
"Partial update of the calling user's AI-provider configuration. An omitted key is left untouched; `null` (or an empty string, on the string fields) clears the column. At least one recognised key must be present, or the write is refused. Key material is write-only: it is encrypted on the way in and never returned. Unknown keys are ignored rather than refused — the object is deliberately not strict, so a client sending a field this server does not know yet is not broken by it.",
});
const aiTestRequest = aiTestOverrideSchema.meta({
id: "AiTestRequest",
@@ -767,7 +701,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Update the calling user's AI-provider configuration",
description:
"Partial update — an omitted key is left untouched, an explicit `null` (or an empty string, on the string fields) clears the column. Key material is write-only: it is encrypted at rest on the way in and the read never gives it back, only presence plus a four-character preview.\n\nOne implicit write is worth knowing about: switching `provider` to anything other than LOCAL, without naming `baseUrl` in the same request, CLEARS the stored base URL. The column is shared across providers, so leaving it would send a cloud key to a host that was configured for a self-hosted backend.\n\nA base URL — for LOCAL or for the OpenAI-compatible gateway — runs the SSRF floor: a private or internal host is refused unless the operator allowlisted that exact hostname on this instance.\n\nThe response is a bare `{ updated: true }` acknowledgement. It does not echo the resolved configuration, so a client that needs the new state re-reads the GET.\n\nUnlike its neighbours this route does not run a Zod parse: it inspects the body field by field, so a key of the WRONG TYPE is skipped in silence rather than refused, and a body of nothing but such keys comes back as 422 'No valid fields' without naming any of them.",
"Partial update — an omitted key is left untouched, an explicit `null` (or an empty string, on the string fields) clears the column. Key material is write-only: it is encrypted at rest on the way in and the read never gives it back, only presence plus a four-character preview.\n\nOne implicit write is worth knowing about: switching `provider` to anything other than LOCAL, without naming `baseUrl` in the same request, CLEARS the stored base URL. The column is shared across providers, so leaving it would send a cloud key to a host that was configured for a self-hosted backend.\n\nA base URL — for LOCAL or for the OpenAI-compatible gateway — runs the SSRF floor: a private or internal host is refused unless the operator allowlisted that exact hostname on this instance.\n\nThe response is a bare `{ updated: true }` acknowledgement. It does not echo the resolved configuration, so a client that needs the new state re-reads the GET.\n\nA wrongly-typed value is refused, not skipped. Until this release the route inspected the body field by field with `typeof` guards, so a numeric `model` or a boolean `baseUrl` was dropped in silence and the write went ahead without it; a body of nothing but such keys came back as 'No valid fields' naming none of them. It runs the standard Zod parse now and answers the multi-issue 422 with the offending path.",
requestBody: {
required: true,
content: { "application/json": { schema: aiProviderPatchRequest } },
@@ -796,7 +730,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"Nothing was written. Either no recognised field was present ('No valid fields'), or `provider` named a value outside the five, or `responseTimeoutSeconds` was not an integer in 10–600, or a base URL pointed at an internal or private host the operator has not allowlisted. Single-message — there is no per-issue list on this route.",
"Nothing was written. A body that failed validation carries the multi-issue list under `details.issues` with `meta.errorCode` = `ai_provider.invalid` — a wrongly-typed field, a `provider` outside the five, or a `responseTimeoutSeconds` that is not an integer in 10–600 all land there and name their path. Two single-message cases stay outside that list because they are not shape failures: no recognised field was present at all (`meta.errorCode` = `ai_provider.no_fields`), and a base URL pointing at an internal or private host the operator has not allowlisted.",
content: { "application/json": { schema: errorEnvelope } },
},
},