Files
HealthLog/src/lib/openapi/routes/shared.ts
T
Marc-André Bombeck 3090b742c4 docs(openapi): document the profile module map and the 403 module.disabled envelope
Add the resolved `modules` map to ProfileResponse (shared ModuleMap
schema) so the spec matches the alias payload, and add a reusable
module-disabled 403 response documenting `meta.errorCode = module.disabled`
plus `meta.module` (the disabled key). Wire that 403 onto the module-gated
sleep night/rhythm routes, which emit it via requireModuleEnabled.
Regenerate docs/api/openapi.yaml; openapi:check is in sync.
2026-06-16 00:36:05 +02:00

143 lines
6.0 KiB
TypeScript

/**
* Shared OpenAPI building blocks — response envelopes and standard error responses used across every route module.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import { z } from "zod/v4";
import {
createMeasurementSchema,
listMeasurementsSchema,
measurementTypeEnum,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { loginPasswordSchema } from "@/lib/validations/auth";
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
/**
* Common envelopes — every HealthLog API response wraps payload in
* `{ data, error, meta? }`. The OpenAPI surface mirrors that contract
* so iOS / external-ingest clients can decode uniformly.
*/
export const errorEnvelope = z
.object({
data: z.null(),
error: z.string(),
meta: z
.object({
requestId: z.string().optional(),
errorCode: z.string().optional(),
})
.optional(),
})
.meta({
id: "ErrorEnvelope",
description: "Standard error response: data is null, error is human prose.",
});
export function dataEnvelope<T extends z.ZodType>(payload: T, id: string) {
return z
.object({
data: payload,
error: z.null(),
meta: z.object({ requestId: z.string().optional() }).optional(),
})
.meta({ id });
}
// ── Schemas — annotated for spec emission ────────────────────────────
measurementTypeEnum.meta({
id: "MeasurementType",
description:
"DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature).",
});
measurementSourceEnum.meta({
id: "MeasurementSource",
description:
"Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path.",
});
loginPasswordSchema.meta({
id: "LoginPasswordRequest",
description:
"Email-or-username login. The native-client flow returns a paired access + refresh token when X-Client-Type: native or the iOS UA prefix is present.",
});
createMeasurementSchema.meta({
id: "CreateMeasurementRequest",
description:
"Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422.",
});
listMeasurementsSchema.meta({
id: "ListMeasurementsQuery",
description:
"Query params for the measurements list endpoint. `limit` capped at 500.",
});
coachPrefsSchema.meta({
id: "CoachPrefs",
description:
"Per-user Coach prompt-tuning preferences (v1.4.23 H4). All fields default to the legacy v1.4.22 behaviour when omitted.",
});
createBatchWorkoutSchema.meta({
id: "CreateBatchWorkoutRequest",
description:
"Typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route AND an optional route-independent per-workout heart-rate series (`samples`: `[{ t, hr?, speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).",
});
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
export const stdResponses = {
"401": {
description: "Authentication required or invalid credentials.",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description: "Request validation failed.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description: "Rate limit exceeded.",
content: { "application/json": { schema: errorEnvelope } },
},
};
// ── AI-consent precondition (v1.16.13) ───────────────────────────────
// The server-managed AI-egress gate requires an active ConsentReceipt
// (`ai_full`, or the surface-specific `ai_insights_only` / `ai_coach`)
// before any health snapshot leaves for the operator's global LLM key.
// Interactive routes surface this as a 403 with
// `meta.errorCode = "consent.ai.required"`; clients render an inline
// grant-consent notice and call POST /api/consent/ai (or, on web, POST
// /api/consent/ai/web) to mint the receipt. BYOK / local / ChatGPT-OAuth
// chains are the user's own egress and never trip this gate.
export const consentRequiredResponse = {
"403": {
description:
"AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying.",
content: { "application/json": { schema: errorEnvelope } },
},
};
// ── Module-disabled gate (v1.18.0) ───────────────────────────────────
// Every module-scoped route runs `requireModuleEnabled(userId, key)`,
// which returns a 403 when the account has the module turned off — even
// with a valid Bearer token. The envelope carries
// `meta.errorCode = "module.disabled"` and `meta.module` (the disabled
// module key) so the iOS retry classifier branches on it and the client
// can drop the whole surface. The errorEnvelope shape already declares
// `meta.errorCode`; `meta.module` is documented here in prose.
export const moduleDisabledResponse = {
"403": {
description:
'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.',
content: { "application/json": { schema: errorEnvelope } },
},
};