Merge remote-tracking branch 'origin/work/contract-and-prefetch' into release/v1.30.32

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 11:52:41 +02:00
commit 306d1f7443
10 files changed
+1897 -189

No files matched your search

+855 -163
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,118 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Structural guard over the server-prefetching pages.
*
* A page that calls `dehydrate()` inside a `HydrationBoundary` serialises the
* caller's own health record into the HTML document. Two things must hold for
* every such page, and neither is enforced by the type system:
*
* 1. It must be session-gated at the edge, so `src/proxy.ts` stamps
* `Cache-Control: private, no-store` on the document. A prefetching page
* placed on a public path would ship a record with no cache directive at
* all.
* 2. It must not opt into static or revalidated rendering. `export const
* revalidate = <n>` or `dynamic = "force-static"` would let Next serve one
* account's prefetched HTML to the next caller out of its own cache,
* before the request ever reaches a header.
*
* The guard walks the app tree rather than naming files, so a NEW prefetching
* page is covered the moment it lands.
*/
const APP_DIR = join(process.cwd(), "src", "app");
/** Mirrors the public-path allowlist in `src/proxy.ts`. */
const PUBLIC_ROUTE_PREFIXES = [
"/auth/",
"/privacy",
"/about",
"/c/",
"/invite/",
"/onboarding",
"/mcp",
"/i18n/",
"/.well-known/",
];
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "__tests__") continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...walk(full));
} else if (entry === "page.tsx" || entry === "page.ts") {
out.push(full);
}
}
return out;
}
/** Turn `src/app/insights/workouts/page.tsx` into `/insights/workouts`. */
function routePathFor(file: string): string {
const rel = relative(APP_DIR, file).split(sep).slice(0, -1);
const segments = rel.filter(
// Route groups `(name)` and parallel/intercepting slots contribute no
// URL segment.
(s) => !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@"),
);
return "/" + segments.join("/");
}
function isPublicRoute(routePath: string): boolean {
return PUBLIC_ROUTE_PREFIXES.some((p) => routePath.startsWith(p));
}
const prefetchingPages = walk(APP_DIR)
.map((file) => ({ file, source: readFileSync(file, "utf8") }))
.filter(
({ source }) =>
source.includes("HydrationBoundary") && source.includes("dehydrate"),
)
.map(({ file, source }) => ({
file,
source,
routePath: routePathFor(file),
rel: relative(process.cwd(), file),
}));
describe("server-prefetching pages cannot leak a cacheable record", () => {
it("finds the prefetching pages at all (guard is not vacuous)", () => {
expect(prefetchingPages.length).toBeGreaterThan(0);
// The dashboard is the canonical one; if it stops matching, the detection
// heuristic has drifted and every assertion below is silently skipped.
expect(prefetchingPages.map((p) => p.routePath)).toContain("/");
});
it("keeps every prefetching page behind the session gate", () => {
const publicOnes = prefetchingPages.filter((p) =>
isPublicRoute(p.routePath),
);
expect(
publicOnes.map((p) => `${p.rel} (${p.routePath})`),
"a page that dehydrates a record onto a public path gets no no-store header from the proxy",
).toEqual([]);
});
it("lets no prefetching page opt into static or revalidated rendering", () => {
const offenders: string[] = [];
for (const page of prefetchingPages) {
if (/export\s+const\s+revalidate\s*=/.test(page.source)) {
offenders.push(`${page.rel}: exports revalidate`);
}
const dynamicMatch = page.source.match(
/export\s+const\s+dynamic\s*=\s*["']([^"']+)["']/,
);
if (dynamicMatch && dynamicMatch[1] !== "force-dynamic") {
offenders.push(`${page.rel}: exports dynamic = "${dynamicMatch[1]}"`);
}
}
expect(
offenders,
"a prefetched page must never be served from Next's own render cache",
).toEqual([]);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* Session-gated pages serialise one account's health record into the HTML
* document — the dashboard, Insights, the workouts list, the medications
* list and the Coach all server-prefetch their data through a TanStack
* `HydrationBoundary`, so the record ships inside the payload rather than
* arriving over a later fetch.
*
* Those pages are dynamic today only because they read the session cookie.
* That is a property of the page code, not a guarantee — so the proxy pins
* `Cache-Control: private, no-store` on every session-gated page response.
* `private` bars shared caches (a CDN, a reverse proxy); `no-store` also
* bars the browser disk cache and the bfcache snapshot.
*
* These tests pin both sides: gated pages carry the header, and the public
* surfaces that are deliberately cacheable do not.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
beforeEach(() => {
vi.stubEnv("NODE_ENV", "production");
});
afterEach(() => {
vi.unstubAllEnvs();
});
function makeRequest(pathname: string): NextRequest {
return new NextRequest(`http://localhost${pathname}`, {
headers: { cookie: "healthlog_session=sess-1" },
});
}
/** The pages that server-prefetch and dehydrate a record into the HTML. */
const PREFETCHING_PAGES = [
"/",
"/coach",
"/insights",
"/insights/workouts",
"/medications",
];
/** Other session-gated surfaces that render account data. */
const OTHER_GATED_PAGES = [
"/documents",
"/settings/profile",
"/admin/users",
"/mood",
];
describe("proxy.ts no-store on session-gated pages", () => {
it.each(PREFETCHING_PAGES)(
"sets private, no-store on the prefetching page %s",
(path) => {
const res = proxy(makeRequest(path));
const cacheControl = res.headers.get("cache-control") ?? "";
expect(cacheControl).toContain("no-store");
expect(cacheControl).toContain("private");
},
);
it.each(OTHER_GATED_PAGES)(
"sets private, no-store on the gated page %s",
(path) => {
const res = proxy(makeRequest(path));
const cacheControl = res.headers.get("cache-control") ?? "";
expect(cacheControl).toContain("no-store");
expect(cacheControl).toContain("private");
},
);
it("still emits no-store when the request carries no session cookie", () => {
// The unauthenticated caller gets a redirect to /auth/login, which never
// reaches the header block — assert the redirect rather than a header, so
// the test states what actually happens on that arm.
const res = proxy(new NextRequest("http://localhost/medications"));
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/auth/login");
});
it("leaves the public legal + auth surfaces cacheable", () => {
for (const path of ["/privacy", "/about", "/auth/login"]) {
const res = proxy(makeRequest(path));
expect(res.headers.get("cache-control"), path).toBeNull();
}
});
it("leaves the immutable locale catalog cacheable", () => {
const res = proxy(makeRequest("/i18n/en"));
expect(res.headers.get("cache-control")).toBeNull();
});
it("does not blanket API routes, which manage their own caching", () => {
const res = proxy(makeRequest("/api/user/avatar/user-1"));
expect(res.headers.get("cache-control")).toBeNull();
});
it("keeps the stricter share-link posture on /c/<token>", () => {
const token = "hls_000000000000000000000000000000000000000000000000";
const res = proxy(makeRequest(`/c/${token}`));
const cacheControl = res.headers.get("cache-control") ?? "";
expect(cacheControl).toContain("no-store");
expect(cacheControl).toContain("must-revalidate");
});
});
@@ -0,0 +1,249 @@
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { measurementPaths } from "../routes/measurements";
import { workoutPaths } from "../routes/workouts";
/**
* Contract guard: the documented request / response shapes must match what the
* handlers actually put on the wire.
*
* The fixtures below are transcribed from the handler return statements, not
* from the schemas — so a schema that drifts away from the handler fails here
* rather than shipping a spec the iOS client generates the wrong model from.
*
* Sources:
* - src/app/api/measurements/route.ts — the seven `apiSuccess` returns on GET
* (every one of them `{ measurements, meta: { total, limit, offset, … } }`),
* the array-body branch on POST, and the 409 duplicate arm.
* - src/app/api/workouts/route.ts + src/app/api/workouts/[id]/route.ts —
* `requireModuleEnabled(user.id, "workouts")`, which answers 403 with
* `meta.errorCode = "module.disabled"`.
*/
/** Pull the Zod schema out of a registered response entry. */
function responseSchema(
paths: typeof measurementPaths,
path: string,
method: "get" | "post",
status: string,
): z.ZodType {
const responses = paths[path]?.[method]?.responses as
| Record<
string,
{ content?: Record<string, { schema?: unknown }> } | undefined
>
| undefined;
const schema = responses?.[status]?.content?.["application/json"]?.schema;
if (!schema) {
throw new Error(`no ${method.toUpperCase()} ${path} ${status} schema`);
}
return schema as z.ZodType;
}
function requestSchema(
paths: typeof measurementPaths,
path: string,
method: "post",
): z.ZodType {
const operation = paths[path]?.[method];
const schema = operation?.requestBody?.content?.["application/json"]?.schema;
if (!schema)
throw new Error(`no ${method.toUpperCase()} ${path} body schema`);
return schema as z.ZodType;
}
const listResponse = responseSchema(
measurementPaths,
"/api/measurements",
"get",
"200",
);
describe("GET /api/measurements — documented response matches the handler", () => {
it("accepts the default paged envelope (route.ts `{ measurements, meta }`)", () => {
const wire = {
data: {
measurements: [
{
id: "cm000000000000000000000",
userId: "cm111111111111111111111",
type: "WEIGHT",
value: 80.5,
unit: "kg",
source: "MANUAL",
measuredAt: "2026-07-19T08:00:00.000Z",
notes: null,
valueMin: null,
valueMax: null,
externalId: null,
externalSourceVersion: null,
glucoseContext: null,
sleepStage: null,
rhythmClassification: null,
deviceType: null,
syncVersion: 1,
deletedAt: null,
createdAt: "2026-07-19T08:00:00.000Z",
updatedAt: "2026-07-19T08:00:00.000Z",
},
],
meta: { total: 1, limit: 50, offset: 0 },
},
error: null,
};
expect(listResponse.safeParse(wire).success).toBe(true);
});
it("rejects the old `{ measurements, total }` envelope the spec used to claim", () => {
const stale = {
data: { measurements: [], total: 0 },
error: null,
};
expect(listResponse.safeParse(stale).success).toBe(false);
});
it("accepts the collapsed day-sum mode (dayKey / sampleCount / partial)", () => {
const wire = {
data: {
measurements: [
{
id: "day:ACTIVITY_STEPS:2026-07-18",
type: "ACTIVITY_STEPS",
value: 9000,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: "2026-07-18T12:00:00.000Z",
notes: null,
dayKey: "2026-07-18",
sampleCount: 12,
partial: true,
},
],
meta: {
total: 1,
limit: 50,
offset: 0,
groupBy: "day",
droppedDuplicates: 3,
},
},
error: null,
};
expect(listResponse.safeParse(wire).success).toBe(true);
});
it("accepts the aggregate mode's pre-folded buckets", () => {
const wire = {
data: {
measurements: [
{
type: "PULSE",
value: 62.5,
measuredAt: "2026-07-18T00:00:00.000Z",
count: 240,
},
],
meta: { total: 1, limit: 400, offset: 0, aggregate: "daily" },
},
error: null,
};
expect(listResponse.safeParse(wire).success).toBe(true);
});
it("accepts the per-night collapse mode", () => {
const wire = {
data: {
measurements: [
{
id: "sleep-seg:2026-07-18:0",
type: "SLEEP_DURATION",
value: 430,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: "2026-07-18T06:10:00.000Z",
sleepStage: "ASLEEP_CORE",
notes: null,
},
],
meta: { total: 1, limit: 50, offset: 0, groupBy: "night" },
},
error: null,
};
expect(listResponse.safeParse(wire).success).toBe(true);
});
});
describe("POST /api/measurements — documented body + statuses match the handler", () => {
const body = requestSchema(measurementPaths, "/api/measurements", "post");
const created = responseSchema(
measurementPaths,
"/api/measurements",
"post",
"201",
);
const single = {
type: "PULSE",
value: 61,
unit: "bpm",
measuredAt: "2026-07-19T08:00:00.000Z",
};
it("accepts the single-object body", () => {
expect(body.safeParse(single).success).toBe(true);
});
it("accepts the ARRAY body the handler branches on (route.ts `Array.isArray(body)`)", () => {
// The combined blood-pressure write — the reason the array branch exists.
const systolic = {
type: "BLOOD_PRESSURE_SYS",
value: 122,
unit: "mmHg",
measuredAt: "2026-07-19T08:00:00.000Z",
};
const diastolic = { ...systolic, type: "BLOOD_PRESSURE_DIA", value: 78 };
expect(body.safeParse([systolic, diastolic]).success).toBe(true);
});
it("accepts an array of created rows on 201, matching the array branch", () => {
const wire = {
data: [
{
id: "cm000000000000000000000",
type: "PULSE",
value: 61,
unit: "bpm",
source: "MANUAL",
measuredAt: "2026-07-19T08:00:00.000Z",
notes: null,
},
],
error: null,
};
expect(created.safeParse(wire).success).toBe(true);
});
it("documents the 409 the duplicate-dedup arm returns", () => {
const responses = measurementPaths["/api/measurements"]?.post?.responses;
expect(Object.keys(responses ?? {})).toContain("409");
});
});
describe("workouts — the module gate's 403 is documented", () => {
it.each(["/api/workouts", "/api/workouts/{id}"])(
"GET %s declares 403",
(path) => {
const responses = workoutPaths[path]?.get?.responses;
expect(Object.keys(responses ?? {})).toContain("403");
expect(responses?.["403"]?.description).toContain("module.disabled");
},
);
it("leaves the deliberately ungated batch ingest without a module 403", () => {
// src/app/api/workouts/batch/route.ts runs no `requireModuleEnabled` —
// synced data still lands while the surface is hidden.
const responses = workoutPaths["/api/workouts/batch"]?.post?.responses;
expect(Object.keys(responses ?? {})).not.toContain("403");
});
});
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import { authPaths } from "../routes/auth";
import { medicationPaths } from "../routes/medications";
import {
medicationCadenceChips,
medicationDetailEntry,
medicationIntakeEventResource,
medicationListEntry,
} from "../routes/medications/schemas";
/**
* Contract guard for the medications + auth surfaces. Every fixture is
* transcribed from a handler return statement, so a schema that drifts away
* from the handler fails here.
*/
function statusesFor(
paths: typeof authPaths,
path: string,
method: "get" | "post" | "put" | "patch" | "delete",
): string[] {
const responses = paths[path]?.[method]?.responses as
Record<string, unknown> | undefined;
return Object.keys(responses ?? {});
}
function hasParams(
paths: typeof authPaths,
path: string,
method: "get" | "delete" | "patch",
): boolean {
return Boolean(paths[path]?.[method]?.requestParams);
}
describe("medications — documented shapes match the handlers", () => {
it("keeps `nextDueAt` off the create / update result, which returns the stored row", () => {
// src/app/api/medications/route.ts — `apiSuccess({ ...medication,
// unitsPerDose, category }, 201)`, and [id]/route.ts for the PUT. Both
// spread the stored Prisma row; `nextDueAt` / `nextDueOverdue` are
// computed by the READ paths only and are simply absent here. The shared
// detail shape backs BOTH the write results and the single GET, so it
// cannot require them.
expect(
medicationDetailEntry.shape.nextDueAt.safeParse(undefined).success,
).toBe(true);
expect(
medicationDetailEntry.shape.nextDueOverdue.safeParse(undefined).success,
).toBe(true);
});
it("still requires the computed due fields on a list row, which always has them", () => {
// src/lib/medications/list-read.ts computes both on every row, so the
// list entry must not inherit the base's optionality.
expect(
medicationListEntry.shape.nextDueAt.safeParse(undefined).success,
).toBe(false);
expect(
medicationListEntry.shape.nextDueOverdue.safeParse(undefined).success,
).toBe(false);
});
it("accepts a fractional stock unit count (unitsRemaining is Decimal(12,4))", () => {
// src/lib/medications/list-read.ts sums `unitsRemaining`, which the schema
// itself documents as fractional ("a half-tablet dose leaves 29.5 of 30").
const shape = medicationListEntry.shape.stockUnitsRemaining;
expect(shape.safeParse(29.5).success).toBe(true);
});
it("matches `complianceChips()`'s actual return on the cadence chips", () => {
// src/lib/medications/scheduling/compliance.ts returns exactly these five.
const chips = {
adherenceRate: null,
currentStreak: 0,
longestStreak: 4,
missedLast30: 2,
windowDays: 30,
};
const parsed = medicationCadenceChips.safeParse(chips);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});
it("rejects the fields the cadence chips never carried", () => {
const stale = {
adherenceRate: 90,
streakDays: 3,
expectedSlots: 30,
actualDoses: 27,
};
expect(medicationCadenceChips.safeParse(stale).success).toBe(false);
});
it("admits APPLE_HEALTH as an intake source (IntakeSource enum, v1.28)", () => {
const event = {
id: "cm000000000000000000000",
userId: "cm111111111111111111111",
medicationId: "cm222222222222222222222",
scheduledFor: "2026-07-19T08:00:00.000Z",
takenAt: "2026-07-19T08:05:00.000Z",
skipped: false,
source: "APPLE_HEALTH",
idempotencyKey: null,
createdAt: "2026-07-19T08:05:00.000Z",
};
const parsed = medicationIntakeEventResource.safeParse(event);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});
});
describe("auth — documented shapes and statuses match the handlers", () => {
it("documents the 403 oidc_only refusal on password login", () => {
expect(statusesFor(authPaths, "/api/auth/login", "post")).toContain("403");
});
it("documents the 403 + 404 arms on passkey assertion verification", () => {
const statuses = statusesFor(
authPaths,
"/api/auth/passkey/login-verify",
"post",
);
expect(statuses).toContain("403");
expect(statuses).toContain("404");
});
it("documents the 409 on both TOTP enrolment steps", () => {
expect(
statusesFor(authPaths, "/api/auth/me/mfa/totp/setup", "post"),
).toContain("409");
expect(
statusesFor(authPaths, "/api/auth/me/mfa/totp/confirm", "post"),
).toContain("409");
});
it("documents the 400 on a failed security-key attestation", () => {
expect(
statusesFor(
authPaths,
"/api/auth/me/mfa/webauthn/register/verify",
"post",
),
).toContain("400");
});
it("documents the 409 when no security key is registered to assert against", () => {
expect(
statusesFor(authPaths, "/api/auth/mfa/webauthn/verify/options", "post"),
).toContain("409");
});
it("documents the 404 on both security-key {id} verbs", () => {
const path = "/api/auth/me/mfa/webauthn/{id}";
expect(statusesFor(authPaths, path, "patch")).toContain("404");
expect(statusesFor(authPaths, path, "delete")).toContain("404");
});
it("declares the path / query parameters the handlers actually read", () => {
expect(hasParams(authPaths, "/api/auth/me/sessions/{id}", "delete")).toBe(
true,
);
expect(
hasParams(authPaths, "/api/auth/me/trusted-devices/{id}", "delete"),
).toBe(true);
expect(
hasParams(authPaths, "/api/auth/me/mfa/webauthn/{id}", "patch"),
).toBe(true);
expect(hasParams(authPaths, "/api/auth/me/security-activity", "get")).toBe(
true,
);
});
});
describe("medications — the module gate stays documented where it applies", () => {
it("keeps the daily digest's module 403", () => {
// Sanity anchor: an unrelated documented 403 must not regress while the
// medication + auth statuses above are being added.
expect(statusesFor(medicationPaths, "/api/medications", "get")).toContain(
"200",
);
});
});
+73 -5
View File
@@ -66,9 +66,14 @@ const refreshRequest = z
const accessRefreshBundle = z
.object({
user: z.object({ id: z.string(), username: z.string() }).optional(),
token: z.string().describe("Access token (`hlk_<64hex>`)."),
tokenExpiresAt: z.iso.datetime({ offset: true }),
user: z.object({ id: z.string(), username: z.string() }),
token: z
.string()
.optional()
.describe(
"Access token (`hlk_<64hex>`); only present for native-policy callers. A browser caller gets the session cookie instead.",
),
tokenExpiresAt: z.iso.datetime({ offset: true }).optional(),
refreshToken: z
.string()
.optional()
@@ -327,6 +332,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description:
"Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -397,6 +407,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"409": {
description: "A second factor is already active on this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -423,6 +437,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"409": {
description:
"A second factor is already active, or enrollment was never started (no pending secret to confirm).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -537,6 +556,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"400": {
description: "Security key verification failed (bad attestation).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -545,6 +568,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
patch: {
tags: ["Auth"],
summary: "Rename a registered security key",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: mfaWebauthnRenameSchema } },
@@ -561,6 +585,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"404": {
description: "No such security key for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -569,6 +597,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Remove a registered security key (step-up gated)",
description:
"Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Security key removed.",
@@ -581,6 +610,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"404": {
description: "No such security key for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -609,6 +642,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"409": {
description:
"No security key is registered on the account — fall back to the TOTP / recovery-code challenge.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -662,6 +700,16 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description:
"Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description:
"The assertion resolved to a user row that no longer exists.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -679,10 +727,17 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
responses: {
"200": {
description: "Rotation succeeded — new pair issued.",
description:
"Rotation succeeded — new pair issued. When the request carried `revoke: true` the body is `{ revoked }` instead: the token family is invalidated and no new pair is minted.",
content: {
"application/json": {
schema: dataEnvelope(accessRefreshBundle, "RefreshResponse"),
schema: z.union([
dataEnvelope(accessRefreshBundle, "RefreshResponse"),
dataEnvelope(
z.object({ revoked: z.boolean() }),
"RefreshRevokeResponse",
),
]),
},
},
},
@@ -735,6 +790,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Revoke a single web session",
description:
"v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row).",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Session revoked.",
@@ -803,6 +859,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Revoke a single trusted device",
description:
"v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404).",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Trusted device revoked.",
@@ -829,6 +886,17 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "List recent account-security activity",
description:
"v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.",
requestParams: {
query: z.object({
limit: z.coerce
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Page size; defaults to 50, clamped to 100."),
}),
},
responses: {
"200": {
description: "Recent security events for the caller.",
+238 -10
View File
@@ -11,11 +11,17 @@ import {
createMeasurementSchema,
listMeasurementsSchema,
measurementTypeEnum,
updateMeasurementSchema,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { seriesBatchQuerySchema } from "@/lib/validations/series-batch";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared";
import {
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
stdResponses,
} from "./shared";
const batchEntrySchema = z
.object({
@@ -196,19 +202,140 @@ const restoreMeasurementsResponse = z
export const measurementResource = z
.object({
id: z.string(),
id: z
.string()
.describe(
"Stored row id, or a synthetic key (`day:<type>:<dayKey>`, `sleep-seg:<dayKey>:<n>`) on the collapsed list modes.",
),
type: measurementTypeEnum,
value: z.number(),
unit: z.string(),
measuredAt: z.iso.datetime({ offset: true }),
source: measurementSourceEnum,
notes: z.string().nullable().optional(),
// The list / detail reads hand back the whole stored row minus the
// `notesEncrypted` ciphertext, so every remaining scalar column is on the
// wire. Optional rather than required because the collapsed list modes
// (day-sum, sleep-segment) synthesise rows that carry only the display
// fields above.
userId: z.string().optional(),
valueMin: z
.number()
.nullable()
.optional()
.describe(
"Bucket MINIMUM — set only on an hourly heart-rate `stats:` row.",
),
valueMax: z
.number()
.nullable()
.optional()
.describe(
"Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row.",
),
externalId: z
.string()
.nullable()
.optional()
.describe("External-system dedup key; null for manual entries."),
externalSourceVersion: z.string().nullable().optional(),
glucoseContext: z
.string()
.nullable()
.optional()
.describe("Set only on BLOOD_GLUCOSE rows."),
sleepStage: z
.string()
.nullable()
.optional()
.describe("Set only on SLEEP_DURATION rows."),
rhythmClassification: z
.string()
.nullable()
.optional()
.describe("Device classification verdict; set only on EVENT rows."),
deviceType: z
.string()
.nullable()
.optional()
.describe("`watch | band | ring | phone | scale | other | unknown`."),
syncVersion: z
.number()
.int()
.optional()
.describe(
"Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation.",
),
deletedAt: z.iso
.datetime({ offset: true })
.nullable()
.optional()
.describe("Soft-delete tombstone; null on a live row."),
createdAt: z.iso.datetime({ offset: true }).optional(),
updatedAt: z.iso.datetime({ offset: true }).optional(),
// Synthesised-row extras — present only on the collapsed day-sum mode.
dayKey: z.string().optional(),
sampleCount: z.number().int().optional(),
partial: z
.boolean()
.optional()
.describe(
"True on the one bucket whose day was cut off by the read cap, so its sum understates the real total.",
),
})
.meta({
id: "MeasurementResource",
description: "Server-shaped measurement row returned by GET endpoints.",
description:
"Server-shaped measurement row. GET endpoints return the whole stored row with the decrypted note on `notes` and the `notesEncrypted` ciphertext stripped; the collapsed list modes synthesise rows carrying the display fields plus `dayKey` / `sampleCount`.",
});
/**
* The aggregate list modes (`aggregate=daily` and the coarser grains) return
* pre-folded buckets, not stored rows — no `id`, `unit` or `source`.
*/
const aggregatedMeasurementBucket = z
.object({
type: measurementTypeEnum,
value: z.number().describe("The bucket's aggregated value."),
measuredAt: z.iso.datetime({ offset: true }).describe("Bucket start."),
count: z.number().int().nonnegative().optional(),
unit: z.string().optional(),
})
.meta({
id: "AggregatedMeasurementBucket",
description:
"Pre-folded bucket returned by the `aggregate=` list modes instead of a stored row.",
});
/** `meta` on every `GET /api/measurements` response mode. */
const listMeasurementsMeta = z
.object({
total: z.number().int().nonnegative(),
limit: z.number().int().nonnegative(),
offset: z.number().int().nonnegative(),
dayKey: z
.string()
.optional()
.describe("Echoed on the per-sample drill-down modes."),
groupBy: z
.enum(["day", "night"])
.optional()
.describe("Set on the collapsed day-sum / per-night modes."),
aggregate: z
.string()
.optional()
.describe("Set on the aggregate modes — the requested grain."),
droppedDuplicates: z
.number()
.int()
.nonnegative()
.optional()
.describe(
"Rows discarded by the cross-source canonical picker on the day-collapse mode.",
),
})
.meta({ id: "ListMeasurementsMeta" });
// ── Sleep night (v1.11.5 hypnogram source) ──────────────────────────
const sleepStageEnum = z.enum([
@@ -597,8 +724,10 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"application/json": {
schema: dataEnvelope(
z.object({
measurements: z.array(measurementResource),
total: z.number().int().nonnegative(),
measurements: z.array(
z.union([measurementResource, aggregatedMeasurementBucket]),
),
meta: listMeasurementsMeta,
}),
"ListMeasurementsResponse",
),
@@ -610,25 +739,124 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
post: {
tags: ["Measurements"],
summary: "Create one measurement",
summary: "Create one measurement (or a small array)",
description:
"Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.",
"Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.",
requestBody: {
required: true,
content: { "application/json": { schema: createMeasurementSchema } },
content: {
"application/json": {
schema: z.union([
createMeasurementSchema,
z.array(createMeasurementSchema),
]),
},
},
},
responses: {
"201": {
description: "Created.",
description:
"Created. A single resource for the object body; an array of resources, in request order, for the array body.",
content: {
"application/json": {
schema: dataEnvelope(
measurementResource,
z.union([measurementResource, z.array(measurementResource)]),
"CreateMeasurementResponse",
),
},
},
},
"409": {
description:
"A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/measurements/{id}": {
get: {
tags: ["Measurements"],
summary: "Measurement detail",
description:
"Single stored row, scoped to the caller. A row owned by another user surfaces as 404 (existence channel sealed).",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Measurement.",
content: {
"application/json": {
schema: dataEnvelope(
measurementResource,
"GetMeasurementResponse",
),
},
},
},
"404": {
description: "Measurement not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
put: {
tags: ["Measurements"],
summary: "Update a measurement",
description:
"Edits a manually entered row. Rows owned by a connected source are refused with 409 `measurement.update.server_owned_source`; a timestamp collision with an existing row is refused with 409 `measurement.duplicate_timestamp`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateMeasurementSchema } },
},
responses: {
"200": {
description: "Updated measurement.",
content: {
"application/json": {
schema: dataEnvelope(
measurementResource,
"UpdateMeasurementResponse",
),
},
},
},
"404": {
description: "Measurement not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"Refused: the row comes from a connected source (`meta.errorCode` = `measurement.update.server_owned_source`), or another row already carries this timestamp (`measurement.duplicate_timestamp`).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
delete: {
tags: ["Measurements"],
summary: "Delete a measurement",
description:
"Soft-deletes the row (tombstone) so paired clients can reconcile the deletion.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.literal(true) }),
"DeleteMeasurementResponse",
),
},
},
},
"404": {
description: "Measurement not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
+38 -9
View File
@@ -177,13 +177,15 @@ export const medicationResource = z
nextDueAt: z.iso
.datetime({ offset: true })
.nullable()
.optional()
.describe(
"v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.",
"Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.",
),
nextDueOverdue: z
.boolean()
.optional()
.describe(
"v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.",
"Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.",
),
startsOn: z.iso
.datetime({ offset: true })
@@ -220,6 +222,10 @@ export const medicationResource = z
export const medicationListEntry = medicationResource
.extend({
category: medicationCategoryEnum,
// The list read always computes these two, so they are required here
// even though the shared base leaves them optional for the write paths.
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
nextDueOverdue: z.boolean(),
lastTakenAt: z.iso
.datetime({ offset: true })
.nullable()
@@ -235,7 +241,6 @@ export const medicationListEntry = medicationResource
),
stockUnitsRemaining: z
.number()
.int()
.nullable()
.describe(
"v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored.",
@@ -362,7 +367,7 @@ export const medicationIntakeEventResource = z
scheduledFor: z.iso.datetime({ offset: true }),
takenAt: z.iso.datetime({ offset: true }).nullable(),
skipped: z.boolean(),
source: z.enum(["WEB", "API", "REMINDER", "IMPORT"]),
source: z.enum(["WEB", "API", "REMINDER", "IMPORT", "APPLE_HEALTH"]),
idempotencyKey: z.string().nullable(),
createdAt: z.iso.datetime({ offset: true }),
})
@@ -388,15 +393,39 @@ export const medicationCadenceTimelinePoint = z
export const medicationCadenceChips = z
.object({
adherenceRate: z.number(),
streakDays: z.number().int().nonnegative(),
expectedSlots: z.number().int().nonnegative(),
actualDoses: z.number().int().nonnegative(),
adherenceRate: z
.number()
.nullable()
.describe(
"0-100, taken / (taken + missed). Skipped doses are excluded from the denominator — a deliberate decision, not a compliance failure. NULL when no dose was expected in the window (brand-new medication, paused).",
),
currentStreak: z
.number()
.int()
.nonnegative()
.describe(
"Consecutive days ending at `asOf` where every expected dose was taken or skipped. Days with no expected dose advance the streak; missed days break it.",
),
longestStreak: z
.number()
.int()
.nonnegative()
.describe("Longest all-taken-or-skipped run anywhere in the window."),
missedLast30: z
.number()
.int()
.nonnegative()
.describe("Count of missed doses inside the window."),
windowDays: z
.number()
.int()
.nonnegative()
.describe("Window size used — mirrors the input for the chart legend."),
})
.meta({
id: "MedicationCadenceChips",
description:
"Four compliance summary values for the medication detail page chip row.",
"Compliance summary values for the medication detail page chip row.",
});
export const medicationCadenceResponse = z
+8 -1
View File
@@ -9,7 +9,12 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { measurementSourceEnum } from "@/lib/validations/measurement";
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
import {
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
stdResponses,
} from "./shared";
// v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors
// the measurements batch shape but reports the `workouts` count rather
@@ -205,6 +210,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
@@ -234,6 +240,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Workout not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...moduleDisabledResponse,
...stdResponses,
},
},
+26 -1
View File
@@ -245,7 +245,11 @@ export function proxy(request: NextRequest) {
const isApiRoute = pathname.startsWith("/api/");
const isStaticFile = /\.\w+$/.test(pathname);
const isPublic = isPublicPath(pathname);
if (!isApiRoute && !isStaticFile && !isPublic) {
// Every page behind the session gate. The same predicate drives the
// auth redirect below and the `no-store` header further down, so a page
// can never be session-gated without also being uncacheable.
const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic;
if (isSessionGatedPage) {
const hasSession = request.cookies.has("healthlog_session");
if (!hasSession) {
return NextResponse.redirect(new URL("/auth/login", request.url));
@@ -335,6 +339,27 @@ export function proxy(request: NextRequest) {
// Both serve routes take the narrow, document-only header posture.
const isServeRoute = isDocumentServeRoute || isShareDocumentServeRoute;
// Session-gated pages carry one account's health record in the document
// itself. Several of them server-prefetch their data and serialise it into
// the HTML through a TanStack `HydrationBoundary` — the dashboard, Insights,
// the workouts list, the medications list, the Coach. Those pages are
// dynamic today only as a side effect of reading the session cookie, which
// is a property of the page code, not a guarantee: a stray `revalidate`
// export, a `force-static` segment, or a caching proxy in front of the app
// would be enough to retain one account's record and hand it to the next
// caller. Pin the guarantee at the edge instead, where no page-level change
// can weaken it.
//
// `private` bars shared caches (CDN, reverse proxy) outright; `no-store`
// additionally stops the browser's own disk cache and the bfcache snapshot,
// so a back-navigation after logout cannot repaint the record. The public
// surfaces (`/auth/*`, `/privacy`, `/about`, `/i18n/*`) are excluded by the
// same predicate that governs the auth redirect, so the statically
// rendered marketing/legal pages stay cacheable.
if (isSessionGatedPage) {
response.headers.set("Cache-Control", "private, no-store");
}
// Security headers
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY");