mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branch 'origin/work/contract-and-prefetch' into release/v1.30.32
This commit is contained in:
commit
306d1f7443
10 files changed
+1897
-189
No files matched your search
+855
-163
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user