mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(api): publish the v1.43 contract with routes that answer 501 until filled
Every new endpoint of the contract exists, wrapped in apiHandler, with its authentication, module gate and request validation, and answers 501 not_implemented until its behaviour lands. The OpenAPI document carries the new operations and schemas and the additive response fields on the changed routes. The four server feature flags are published as false. contract-stub-inventory.test.ts registers the stubbed routes; each one leaves the register with the change that fills it. The route, sharing, session, security, module-gate and outcome inventories name the new routes deliberately, and the error-code catalogue lists the codes the routes emit today.
This commit is contained in:
1 parent
6e72de1c35
commit
3786deb503
74 files changed
+6614
-282
No files matched your search
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* The routes that publish a contract and do not yet carry its effect.
|
||||
*
|
||||
* v1.43 fixed its API contract before the features behind it were built: the
|
||||
* routes exist, authenticate, gate and validate as documented, and answer
|
||||
* `501 not_implemented` (`src/lib/http/not-implemented.ts`) until their
|
||||
* feature lands. That is safe only while nothing reaches them, and nothing
|
||||
* should: every client path sits behind a `features` flag that stays `false`.
|
||||
*
|
||||
* This list is the debt register. A feature that lands removes its route here
|
||||
* in the same change; a new stub has to be named here to exist. The release is
|
||||
* not ready to tag while the list is not empty, which the last case below
|
||||
* makes impossible to miss: it states the count it was written against, so
|
||||
* the change that empties the list also has to touch this file.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const API_ROOT = join(process.cwd(), "src", "app", "api");
|
||||
|
||||
/** Route file (under `src/app/api`) to the operations it still stubs. */
|
||||
const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
"admin/maintenance/pulse-day-mean/route.ts": [
|
||||
"admin.pulse_day_mean.get",
|
||||
"admin.pulse_day_mean.post",
|
||||
],
|
||||
"admin/support-report/route.ts": ["admin.support_report.get"],
|
||||
"anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"],
|
||||
"anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"],
|
||||
"anamnesis/life-circumstances/ai-sections/route.ts": [
|
||||
"anamnesis.life_circumstances.ai_sections.patch",
|
||||
],
|
||||
"anamnesis/life-circumstances/confirm/route.ts": [
|
||||
"anamnesis.life_circumstances.confirm",
|
||||
],
|
||||
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
|
||||
"auth/claim/sso/route.ts": ["profile_claim.sso.start"],
|
||||
"auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"],
|
||||
"biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"],
|
||||
"dawarich/connect/route.ts": ["dawarich.connect"],
|
||||
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
|
||||
"dawarich/places/route.ts": ["dawarich.places.get"],
|
||||
"dawarich/settings/route.ts": ["dawarich.settings.patch"],
|
||||
"dawarich/status/route.ts": ["dawarich.status.get"],
|
||||
"dawarich/sync/route.ts": ["dawarich.sync"],
|
||||
"dawarich/test/route.ts": ["dawarich.test"],
|
||||
"environment/travel/suggestions/[id]/dismiss/route.ts": [
|
||||
"environment.travel_suggestion.dismiss",
|
||||
],
|
||||
"environment/travel/suggestions/route.ts": [
|
||||
"environment.travel_suggestions.get",
|
||||
],
|
||||
"labs/[id]/links/route.ts": ["labs.link."],
|
||||
"medications/[id]/ingredients/extract/route.ts": [
|
||||
"medications.ingredients.extract",
|
||||
],
|
||||
"medications/[id]/ingredients/route.ts": ["medications.ingredients.put"],
|
||||
"medications/[id]/links/route.ts": ["medication.link."],
|
||||
"nutrients/catalog/route.ts": ["nutrients.catalog.get"],
|
||||
};
|
||||
|
||||
/** The size of the register when it was written; see the header. */
|
||||
const REGISTER_SIZE = 24;
|
||||
|
||||
function routeFiles(): string[] {
|
||||
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
|
||||
.filter((rel) => rel.endsWith("route.ts"))
|
||||
.filter((rel) => !rel.includes("__tests__"));
|
||||
}
|
||||
|
||||
describe("contract stubs answering 501", () => {
|
||||
const routes = routeFiles();
|
||||
const found = routes
|
||||
.filter((rel) =>
|
||||
/\bnotImplemented\s*\(/.test(readFileSync(join(API_ROOT, rel), "utf8")),
|
||||
)
|
||||
.sort();
|
||||
|
||||
it("sweeps the whole route tree", () => {
|
||||
// The register can and should reach zero; the tree it is checked against
|
||||
// cannot. A matcher that read nothing would make the register look clean.
|
||||
expect(routes.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
it("are exactly the registered ones", () => {
|
||||
expect(found).toEqual(Object.keys(CONTRACT_STUBS).sort());
|
||||
});
|
||||
|
||||
it("each still names the operations it was registered with", () => {
|
||||
for (const [rel, operations] of Object.entries(CONTRACT_STUBS)) {
|
||||
const source = readFileSync(join(API_ROOT, rel), "utf8");
|
||||
for (const operation of operations) {
|
||||
expect(source, `${rel} no longer stubs ${operation}`).toContain(
|
||||
`notImplemented(${operation.endsWith(".") ? "`" : '"'}${operation}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("authenticate before they answer", () => {
|
||||
for (const rel of found) {
|
||||
const source = readFileSync(join(API_ROOT, rel), "utf8");
|
||||
expect(
|
||||
/\b(requireAuth|requireRecordAuth|requireAdmin|claimGate)\s*\(/.test(
|
||||
source,
|
||||
),
|
||||
`${rel} answers 501 without authenticating first`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("are still the register the release was planned with", () => {
|
||||
// Empty before the release is tagged: set REGISTER_SIZE to 0 with the
|
||||
// change that fills the last route.
|
||||
expect(Object.keys(CONTRACT_STUBS)).toHaveLength(REGISTER_SIZE);
|
||||
});
|
||||
});
|
||||
@@ -258,6 +258,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/maintenance/pulse-day-mean": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
"/api/admin/monitoring/glitchtip-test": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
@@ -284,6 +288,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/status": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
|
||||
|
||||
@@ -83,6 +83,8 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
|
||||
"Previews a managed-profile handover link for a person who has no credential yet; the one-time token in the body is the credential.",
|
||||
"/api/auth/claim":
|
||||
"Turns a managed profile into the claimant's own account and creates their first credential; the one-time token in the body authorises it.",
|
||||
"/api/auth/claim/sso":
|
||||
"Starts claiming a managed profile through the identity provider; the claimant has no credential yet, and the one-time token in the body authorises it.",
|
||||
// The tombstones, sourced from the registry rather than restated one by one.
|
||||
// A retired path answers 410 from the proxy before any handler runs, and that
|
||||
// answer never reads the request's cookies or Authorization header — so it is
|
||||
|
||||
@@ -96,6 +96,9 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
"refuses to claim a profile over a live session",
|
||||
"app/api/auth/claim/preview/route.ts":
|
||||
"refuses to preview a handover link over a live session",
|
||||
// v1.43 (#959) — the single-sign-on claim start, same argument.
|
||||
"app/api/auth/claim/sso/route.ts":
|
||||
"refuses to start a single-sign-on claim over a live session",
|
||||
// Anonymous IdP callback. Probes for an already-valid session so a
|
||||
// replayed callback redirects instead of minting a second identity.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
|
||||
@@ -887,6 +887,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
|
||||
},
|
||||
"app/api/anamnesis/life-circumstances/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches, confirmations and AUDIT-C are written through their own routes, none of them delegable.",
|
||||
},
|
||||
"app/api/profile/summary/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The bounded shared profile read combines only the record's allergies, family history, and current lifestyle facts. It carries no settings, AI configuration, mutations, or encrypted free text.",
|
||||
@@ -931,6 +935,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "measurements",
|
||||
why: "The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.",
|
||||
},
|
||||
"app/api/nutrients/catalog/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The nutrient catalogue as the composition form reads it (v1.43): codes, units and upper limits, labels in the record's language. Reference data the record's module gate opens; nothing of the record itself.",
|
||||
},
|
||||
"app/api/nutrients/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.",
|
||||
@@ -2104,8 +2112,13 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
*
|
||||
* v1.42.4 -- the Labs list's per-marker overview joins the record list
|
||||
* beside `GET /api/labs`, the rows it groups. 255 -> 256.
|
||||
*
|
||||
* v1.43 -- two reads join the record list: the life circumstances beside the
|
||||
* facts (`profile`) and the nutrient catalogue beside the nutrient reads
|
||||
* (`measurements`). The biomarker DELETE joins the manage literal on a module
|
||||
* that was already there. 256 -> 258.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 256;
|
||||
const FROZEN_ENTRY_COUNT = 258;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -754,9 +754,20 @@ const OCR_COLLECTION_CONSUMERS: Readonly<Record<string, readonly string[]>> = {
|
||||
skipped: ["result.skipped.length"],
|
||||
};
|
||||
|
||||
/**
|
||||
* `/api/<provider>/sync` routes that only queue a background job and answer
|
||||
* 202 `{ enqueued }`: nothing is written while the request runs, so there is
|
||||
* no written outcome to resolve. Each entry says why.
|
||||
*/
|
||||
const QUEUE_ONLY_SYNC_ROUTES: Readonly<Record<string, string>> = {
|
||||
dawarich:
|
||||
"Dawarich daily values are fetched by the dawarich-sync job; the route answers 202 { enqueued } and the outcome reaches the card through the connection health, not this response.",
|
||||
};
|
||||
|
||||
function discoveredSyncRoutes(): Array<{ id: string; path: string }> {
|
||||
return readdirSync(API_ROOT, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (!entry.isDirectory()) return [];
|
||||
if (entry.name in QUEUE_ONLY_SYNC_ROUTES) return [];
|
||||
const path = join(API_ROOT, entry.name, "sync", "route.ts");
|
||||
return existsSync(path) ? [{ id: entry.name, path }] : [];
|
||||
});
|
||||
@@ -1078,6 +1089,13 @@ describe("written-outcome response consumer guard", () => {
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
for (const [id, reason] of Object.entries(QUEUE_ONLY_SYNC_ROUTES)) {
|
||||
expect(
|
||||
existsSync(join(API_ROOT, id, "sync", "route.ts")),
|
||||
`${id} is listed as a queue-only sync route but has none`,
|
||||
).toBe(true);
|
||||
expect(reason.length).toBeGreaterThan(40);
|
||||
}
|
||||
const discovered = discoveredSyncRoutes().sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user