Merge remote-tracking branch 'origin/main' into feat/lab-analyte-key-fill

This commit is contained in:
kilian committed 2026-10-11 19:48:56 +02:00
commit c2be166f72
2097 files changed
+214819 -26009

No files matched your search

@@ -153,6 +153,12 @@ const ACTIVE_RECORD_ONLY_FIELDS = [
* only when the claim is true and checkable.
*/
const NO_CLIENT_CONSUMER: Record<string, string> = {
features:
"A capability probe for clients released apart from the server: the native app asks " +
"whether this server takes the record-only medication switch before the account has a " +
"medication to learn it from (iOS #116, item 18). The web client is built into the same " +
"image as the server, so everything it renders is by construction supported; a web read " +
"of the flag would be a read written to satisfy this guard.",
insurerIkNumber:
"No web surface reads or writes it — the account form offers the insurer name and the " +
"insurance number, not the institution number. The native client does consume it: it " +
@@ -229,6 +229,9 @@ describe("grant management is not delegable by construction", () => {
"app/api/account/grants/[id]/route.ts",
"app/api/account/grants/[id]/accept/route.ts",
"app/api/account/grants/[id]/renounce/route.ts",
// v1.42 (#959) — the new owner's decision about former Guardians ends and
// writes grant rows, so it is a grant route like the four above.
"app/api/account/handover-decision/route.ts",
];
it("every grant route exists and resolves through bare requireAuth", () => {
@@ -300,6 +303,20 @@ describe("ending a grant and clearing the switch are one act", () => {
expect(body).toContain("clearActingSessions");
});
it("ends a handover Guardian's row and clears their switch in the caller's transaction", () => {
// v1.42 (#959) — the one other place a grant ends: settling a Guardian's
// access when a managed profile is claimed or decided. It runs inside the
// claim's own transaction (it takes `tx`), so the guard here is that the
// cleanup is part of the same function as the ending.
const body = functionBody(
read("lib/sharing/grants.ts"),
"settleHandoverGuardianGrant",
);
expect(body.length).toBeGreaterThan(0);
expect(body).toContain("revokedAt: input.now");
expect(body).toContain("clearActingSessions(pair, tx)");
});
it("routes an ending through the cleanup, never through the bare transition", () => {
for (const rel of [
"app/api/account/grants/[id]/route.ts",
@@ -0,0 +1,176 @@
/**
* v1.41 — the operator's reasoning switch and cap cannot be bypassed.
*
* Two halves.
*
* Behaviour: `resolveReasoning` is the one decision. With the switch off it
* answers `off` for the Coach and every background job, whatever the person
* chose, whoever pays and whatever the provider can do; with it on, no answer
* is above the cap. Proven exhaustively over the whole input space.
*
* Structure: a call carries reasoning only through `CompletionParams.reasoning`.
* Every production file outside the provider clients that puts that key on a
* call must take its value from the resolver (`@/lib/ai/reasoning/resolve` or
* `@/lib/ai/reasoning/controls`). A file that hand-built `{ effort: "high" }`
* would skip the switch, and goes red here.
*
* Its limit: the structural half reads source text for the setter shapes in
* use (`reasoning: { effort`, `{ reasoning }`, `reasoning: completionReasoning(`,
* `reasoning: resolveJobReasoning(`). A value laundered through another name
* slips it; the resolver import is still required of the file that sets it.
*
* Mutation check: replace `resolveJobReasoning(...)` in `status-provider.ts`
* with `{ effort: "high", summaries: false }` and drop the import: red.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
BACKGROUND_REASONING_JOBS,
REASONING_LEVELS,
REASONING_MAX_EFFORTS,
} from "@/lib/ai/reasoning/levels";
import {
completionReasoning,
resolveReasoning,
type ReasoningSurface,
} from "@/lib/ai/reasoning/resolve";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const SURFACES: ReasoningSurface[] = ["coach", ...BACKGROUND_REASONING_JOBS];
describe("the switch and the cap win, behaviourally", () => {
const supports = [
null,
{
effort: true,
summaries: true,
liveSummaries: true,
stateRoundTrip: true,
offIsReal: false,
},
];
it("off reaches no surface as anything but off", () => {
let cases = 0;
for (const surface of SURFACES)
for (const userPref of REASONING_LEVELS)
for (const costOwner of ["user", "operator"] as const)
for (const support of supports) {
const r = resolveReasoning({
surface,
userPref,
admin: { enabled: false, maxEffort: "high" },
costOwner,
support,
});
expect(r.effort).toBe("off");
expect(r.source).toBe("admin_off");
const sent = completionReasoning(r, surface);
// A job sends nothing; the Coach sends an explicit off.
if (surface === "coach") {
expect(sent).toEqual({ effort: "off", summaries: false });
} else {
expect(sent).toBeUndefined();
}
cases++;
}
expect(cases).toBeGreaterThanOrEqual(100);
});
it("no surface ever resolves above the cap", () => {
for (const surface of SURFACES)
for (const userPref of REASONING_LEVELS)
for (const maxEffort of REASONING_MAX_EFFORTS) {
const r = resolveReasoning({
surface,
userPref,
admin: { enabled: true, maxEffort },
costOwner: "user",
});
expect(REASONING_LEVELS.indexOf(r.effort)).toBeLessThanOrEqual(
REASONING_LEVELS.indexOf(maxEffort),
);
}
});
});
/** The provider clients map an already-resolved value onto their wire. */
const WIRE_LAYER = (rel: string) =>
/^lib\/ai\/[a-z-]+-client\.ts$/.test(rel) ||
rel === "lib/ai/provider-runner.ts" ||
rel.startsWith("lib/ai/reasoning/");
const SETTERS = [
/\breasoning\s*:\s*\{\s*effort\b/,
/\{\s*reasoning\s*\}/,
/\breasoning\s*:\s*(?:completionReasoning|resolveJobReasoning)\s*\(/,
];
const RESOLVER_IMPORT =
/from\s+["']@\/lib\/ai\/reasoning\/(?:resolve|controls)["']/;
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
describe("every call that carries reasoning took it from the resolver", () => {
const files = walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
// Client components bind a form field called `reasoning`, not a call.
.filter((p) => !p.startsWith("components/"))
.filter((p) => !WIRE_LAYER(p));
const setters = files.filter((rel) => {
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
return SETTERS.some((re) => re.test(code));
});
it("finds the setters (an empty set is a failure, not a pass)", () => {
expect(setters.length).toBeGreaterThanOrEqual(2);
expect(setters).toContain("lib/insights/status-provider.ts");
expect(setters).toContain("lib/insights/briefing-provider.ts");
});
it("each one imports the resolver", () => {
expect(
setters.filter(
(rel) => !RESOLVER_IMPORT.test(readFileSync(join(SRC, rel), "utf8")),
),
"A call carries reasoning that did not come from resolveReasoning, so the operator's switch and cap do not reach it.",
).toEqual([]);
});
});
/**
* The Coach turn receives its level on `TurnInput.reasoningLevel` and carries
* it through the loop under its own names, which the setter shapes above do
* not see. The one place that level is decided is the chat route, so the
* route is pinned: it resolves the level, and it does not fall back to a
* constant that would skip the operator's switch.
*/
describe("the Coach turn's level comes from the resolver", () => {
const route = stripComments(
readFileSync(join(SRC, "app/api/insights/chat/route.ts"), "utf8"),
);
it("resolves the level in the chat route", () => {
expect(route).toMatch(
/reasoningLevel[^=]*=\s*await\s+resolveCoachTurnReasoningLevel\s*\(/,
);
expect(route).toMatch(RESOLVER_IMPORT);
});
it("never hands the turn a constant level", () => {
expect(route).not.toMatch(/\bDEFAULT_REASONING_LEVEL\b/);
expect(route).not.toMatch(/reasoningLevel\s*:\s*["']/);
});
});
@@ -0,0 +1,133 @@
/**
* Structural guard (#1126): `reasoning_effort` reaches a model only through
* the setting the resolver bound to the provider instance.
*
* Default must send nothing, so the key may not appear on any wire except as
* the bound value or the call's own resolved level, and only the two clients
* whose endpoint a person chose may carry it. Pinned here:
*
* 1. the key appears in exactly the Local client and the OpenAI client,
* twice each: once as `reasoning_effort: this.reasoningEffort`, sent
* only when the call carries no level of its own (`!params.reasoning`),
* and once as the call's level (v1.41 `CompletionParams.reasoning`,
* resolved upstream against the person's choice and the operator's
* cap), never as a literal;
* 2. nothing but the binder assigns `.reasoningEffort`;
* 3. every exported resolver in `provider.ts` calls `bindReasoningEffort`.
*
* That clients are constructed only in `provider.ts` (so a resolver always
* binds them) is frozen by `ai-call-timeout-guard.test.ts`.
*
* Its limit: it reads source text. A body built under a computed key, or a
* resolver that binds through an alias, would slip it.
* `reasoning-effort.test.ts` and `provider-reasoning-effort.test.ts` prove
* the behaviour on the wire.
*
* Mutation check: hard-code `reasoning_effort: "none"` into either client,
* drop the `!params.reasoning` condition in front of the bound value,
* set `client.reasoningEffort = ...` in a route, or drop `bindReasoningEffort`
* from `resolveProviderForTest`: each one goes red here.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
function code(rel: string): string {
return stripComments(readFileSync(join(SRC, rel), "utf8"));
}
const files = walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
describe("reasoning_effort on the wire", () => {
// As an object key: the published descriptions name the field in prose.
const KEY = /\breasoning_effort["']?\s*:/g;
const sites = files.filter((rel) => new RegExp(KEY).test(code(rel)));
it("appears in the Local and the OpenAI client only", () => {
expect(sites.sort()).toEqual([
"lib/ai/local-client.ts",
"lib/ai/openai-client.ts",
]);
});
it.each(["lib/ai/local-client.ts", "lib/ai/openai-client.ts"])(
"%s sends the bound value only without a call level, and never a literal",
(rel) => {
const source = code(rel);
expect(source.match(new RegExp(KEY))?.length).toBe(2);
expect(source).toMatch(
/\breasoning_effort\s*:\s*this\s*\.\s*reasoningEffort\b/,
);
expect(source).toMatch(
/!\s*params\s*\.\s*reasoning\s*&&\s*(?:this\s*\.\s*isGateway\s*&&\s*)?this\s*\.\s*reasoningEffort\b/,
);
expect(source).not.toMatch(/\breasoning_effort["']?\s*:\s*["'`]/);
},
);
});
/** Where `.reasoningEffort` may be assigned, and why. */
const ASSIGNMENT_SITES: Record<string, string> = {
"lib/ai/reasoning-effort.ts":
"The binder, the one place a provider instance gets its value.",
"lib/ai/provider-chain.ts":
"Writes the stored chain entry, not a provider instance.",
};
describe("only the binder assigns it to a provider", () => {
it("names every assignment site, with a reason", () => {
const sites = files.filter((rel) =>
/\.\s*reasoningEffort\s*=(?!=)/.test(code(rel)),
);
expect(sites.sort()).toEqual(Object.keys(ASSIGNMENT_SITES).sort());
});
});
describe("every exported resolver binds it", () => {
const source = code("lib/ai/provider.ts");
const resolvers = source
.split(/\nexport\s+/)
.map((chunk) => {
const m = /^async\s+function\s+(\w+)\s*\(/.exec(chunk);
if (!m) return null;
const header = chunk.slice(0, chunk.indexOf("{\n"));
if (
!/Promise<\s*(?:AIProvider|ProviderChainResolved\[\])\s*>/.test(header)
) {
return null;
}
const end = chunk.indexOf("\n}\n");
return { name: m[1], body: chunk.slice(header.length, end) };
})
.filter((r): r is { name: string; body: string } => r !== null);
it("finds the resolvers (an empty set is a failure, not a pass)", () => {
expect(resolvers.map((r) => r.name).sort()).toEqual([
"resolveProvider",
"resolveProviderChain",
"resolveProviderForTest",
]);
});
it.each(resolvers.map((r) => r.name))(
"%s calls bindReasoningEffort",
(name) => {
const resolver = resolvers.find((r) => r.name === name);
expect(resolver?.body ?? "").toMatch(/\bbindReasoningEffort\s*\(/);
},
);
});
@@ -0,0 +1,241 @@
/**
* A write that is not activity keeps the row's `updatedAt`.
*
* Prisma's `@updatedAt` stamps "now" on every `update` / `updateMany` /
* `upsert` that does not name the column. That is right for an edit and
* wrong for a one-shot rewrite of how a value is stored: the title
* encryption backfill bumped every Coach conversation it touched, and the
* Coach panel, which groups and orders conversations by `updatedAt`, then
* showed months-old threads under "Today". The same column is the sync
* cursor for measurements and mood entries, so a storage-only rewrite there
* made every client pull rows that had not changed.
*
* Two freezes:
*
* 1. Every Prisma write in a backfill (`lib/jobs/*-backfill.ts`,
* `scripts/backfill-*.ts`) on a model with an `@updatedAt` column names
* `updatedAt` in the call (carrying the row's own value forward), or is
* listed below with the reason the stamp is wanted.
* 2. Every `coachConversation` write anywhere names `updatedAt`, or is
* listed as something the person did in the conversation. Background
* bookkeeping (the memory summary) writes through raw SQL, which Prisma
* does not stamp.
*
* A tripwire, not a proof: it cannot tell whether a listed reason is still
* true, only that the set did not change without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const ROOT = process.cwd();
const SRC = join(ROOT, "src");
/** Models (as Prisma client accessors) that carry an `@updatedAt` column. */
function modelsWithUpdatedAt(): Set<string> {
const schema = readFileSync(join(ROOT, "prisma/schema.prisma"), "utf8");
const out = new Set<string>();
for (const m of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
if (/@updatedAt\b/.test(m[2])) {
out.add(m[1][0].toLowerCase() + m[1].slice(1));
}
}
return out;
}
const WRITE_RE = /\.\s*([a-zA-Z]+)\s*\.\s*(update|updateMany|upsert)\s*\(/g;
/** The argument text of the call whose `(` sits at `open`. */
function callArgs(source: string, open: number): string {
let depth = 0;
for (let i = open; i < source.length; i++) {
const c = source[i];
if (c === "(") depth++;
else if (c === ")") {
depth--;
if (depth === 0) return source.slice(open, i + 1);
}
}
return source.slice(open);
}
interface Write {
file: string;
model: string;
namesUpdatedAt: boolean;
}
function writesIn(file: string, source: string): Write[] {
const out: Write[] = [];
for (const m of source.matchAll(WRITE_RE)) {
const open = (m.index ?? 0) + m[0].length - 1;
out.push({
file,
model: m[1],
namesUpdatedAt: /\bupdatedAt\s*:/.test(callArgs(source, open)),
});
}
return out;
}
function backfillFiles(): string[] {
const jobs = readdirSync(join(SRC, "lib/jobs"))
.filter((f) => /-backfill\.ts$/.test(f))
.map((f) => `src/lib/jobs/${f}`);
const scripts = readdirSync(join(ROOT, "scripts"))
.filter((f) => /^backfill-.*\.ts$/.test(f))
.map((f) => `scripts/${f}`);
return [...jobs, ...scripts].sort();
}
/**
* Backfill writes that stamp `updatedAt` on purpose, keyed `file > model`.
*/
const BACKFILL_STAMPS: Record<string, string> = {
"src/lib/jobs/fitbit-backfill.ts > fitbitConnection":
"the job's own progress on the connection row; not a listed record",
"src/lib/jobs/google-health-backfill.ts > googleHealthConnection":
"the job's own progress on the connection row; not a listed record",
"src/lib/jobs/whoop-backfill.ts > whoopConnection":
"the job's own progress on the connection row; not a listed record",
"src/lib/jobs/sleep-timeline-backfill.ts > whoopConnection":
"marks the connection's one-shot as done; not a listed record",
"src/lib/jobs/sleep-timeline-backfill.ts > withingsConnection":
"marks the connection's one-shot as done; not a listed record",
"src/lib/jobs/strava-backfill.ts > user":
"stamps the account's backfill-completed marker",
"src/lib/jobs/document-content-index-backfill.ts > documentContentIndex":
"the index row is rebuilt content; its updatedAt is the rebuild time",
"src/lib/jobs/lab-biomarker-backfill.ts > biomarker":
"shared catalogue row created or refreshed by the backfill",
"src/lib/jobs/lab-biomarker-backfill.ts > labResult":
"bulk link to a catalogue id; no lab surface orders by updatedAt and a bulk write cannot carry each row's own value",
"src/lib/jobs/free-text-encryption-backfill.ts > measurementReminder":
"bulk clear of a retired readable copy; reminders are listed by schedule, not by updatedAt",
};
/**
* `coachConversation` writes outside backfills that stamp `updatedAt`
* because the person did something in the conversation, keyed by file, with
* how many such writes the file holds. The count matters: keyed by file
* alone, a new background write added beside the listed ones would pass
* unseen.
*/
const COACH_ACTIVITY: Record<string, { writes: number; reason: string }> = {
"src/lib/ai/coach/persistence.ts": {
writes: 2,
reason:
"a rename and a document attach are the person acting on the thread (a turn names updatedAt explicitly)",
},
};
describe("backfills keep updatedAt", () => {
const stamped = modelsWithUpdatedAt();
const files = backfillFiles();
const writes = files.flatMap((f) =>
writesIn(f, readFileSync(join(ROOT, f), "utf8")),
);
it("reads the schema and the backfills at all", () => {
// An empty match set would agree with any allowlist.
expect(stamped.size).toBeGreaterThanOrEqual(40);
expect(stamped.has("coachConversation")).toBe(true);
expect(files.length).toBeGreaterThanOrEqual(10);
expect(writes.length).toBeGreaterThanOrEqual(15);
});
it("carries updatedAt through every storage-only rewrite", () => {
const offenders = writes
.filter((w) => stamped.has(w.model) && !w.namesUpdatedAt)
.map((w) => `${w.file} > ${w.model}`)
.filter((key) => !(key in BACKFILL_STAMPS));
expect(
[...new Set(offenders)],
"a backfill write bumps updatedAt: carry the row's own value (`updatedAt: fresh.updatedAt`), or list it with the reason the stamp is wanted",
).toEqual([]);
});
it("has no stale entries", () => {
const seen = new Set(
writes
.filter((w) => stamped.has(w.model) && !w.namesUpdatedAt)
.map((w) => `${w.file} > ${w.model}`),
);
expect(Object.keys(BACKFILL_STAMPS).filter((k) => !seen.has(k))).toEqual(
[],
);
});
});
describe("coach conversations move only on activity", () => {
const files = walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.map((p) => `src/${p}`);
const writes = [
...files.flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))),
...backfillFiles()
.filter((f) => f.startsWith("scripts/"))
.flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))),
].filter((w) => w.model === "coachConversation");
it("finds the conversation writes at all", () => {
expect(writes.length).toBeGreaterThanOrEqual(3);
});
it("names updatedAt in every write that is not the person's own action", () => {
const stamping = new Map<string, number>();
for (const w of writes.filter((w) => !w.namesUpdatedAt)) {
stamping.set(w.file, (stamping.get(w.file) ?? 0) + 1);
}
const offenders = [...stamping]
.filter(([file, n]) => n !== COACH_ACTIVITY[file]?.writes)
.map(([file, n]) => `${file}: ${n} stamping write(s)`);
expect(
offenders,
"a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL); a new activity write is listed with its count",
).toEqual([]);
// Every listed file is still there with its writes.
expect(Object.keys(COACH_ACTIVITY).filter((f) => !stamping.has(f))).toEqual(
[],
);
});
});
/**
* Key rotation re-seals values in place across every encrypted model, through
* generic delegates the model-named matcher above cannot see. Every write it
* makes names `updatedAt`, carrying the row's own value where the model has
* one; re-sealing under a new key is not an edit.
*/
describe("key rotation keeps updatedAt", () => {
const ROTATION_FILES = [
"src/lib/crypto/encryption-corpus.ts",
"scripts/rotate-encryption-key.ts",
];
const ANY_WRITE_RE = /\.\s*(update|updateMany|upsert)\s*\(/g;
const calls = ROTATION_FILES.flatMap((file) => {
const source = readFileSync(join(ROOT, file), "utf8");
return [...source.matchAll(ANY_WRITE_RE)].map((m) => ({
file,
args: callArgs(source, (m.index ?? 0) + m[0].length - 1),
}));
});
it("finds the rotation writes at all", () => {
expect(calls.length).toBeGreaterThanOrEqual(4);
});
it("names updatedAt in every rotation write", () => {
const offenders = calls
.filter((c) => !/updatedAt/i.test(c.args))
.map((c) => `${c.file}: ${c.args.slice(0, 80).replace(/\s+/g, " ")}`);
expect(
offenders,
"a rotation write must carry the row's own updatedAt",
).toEqual([]);
});
});
@@ -0,0 +1,128 @@
/**
* v1.41 — which background calls may reason, frozen.
*
* Reasoning in the background is paid for every night, for every account, by
* nobody watching. So it is admitted where it changes the result a person
* reads (the daily briefing and the period narratives) and nowhere else: not
* on status cards, batch notes, derived scores, workout and reaction lines,
* nudges, document reads or OCR, which are routine, latency-bound and
* seed-reproducible.
*
* The only way a background call asks is the `reasoningJob` argument of the
* two chokepoints (`runStatusCompletion`, `runBriefingCompletion`). This test
* finds every place that sets it and holds the set, file by file and job by
* job, against the inventory below. A status card that started passing a job
* goes red here; so does a new caller nobody admitted.
*
* Its limit: it reads source text for a `reasoningJob:` property and the job
* literals beside it. A call that built the argument object elsewhere and
* spread it in would slip the matcher; the chokepoint signature is the only
* documented way in, and the wire test (`background-reasoning-wire.test.ts`)
* proves a call without the argument sends nothing.
*
* Mutation check: add `reasoningJob: "daily_briefing"` to
* `src/lib/insights/status-batch.ts`, or delete it from the narrative
* generator: either goes red.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { BACKGROUND_REASONING_JOBS } from "@/lib/ai/reasoning/levels";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** File → the jobs it may name, each with the reason it may. */
const ADMITTED: Record<string, { jobs: string[]; why: string }> = {
"lib/insights/comprehensive-generate.ts": {
jobs: ["daily_briefing"],
why: "The daily briefing's generation and its phrasing re-roll; the JSON and grounding repairs do not reason.",
},
"app/api/insights/generate/route.ts": {
jobs: ["daily_briefing"],
why: "A briefing asked for by hand reasons like the scheduled one; its JSON and grounding repairs do not.",
},
"lib/insights/narrative/period-narrative-generate.ts": {
jobs: ["period_narrative_month", "period_narrative_week"],
why: "The weekly and monthly narratives' first pass; the grounding repair does not reason.",
},
};
/** The routine generators that must never name a job. */
const NEVER = [
"lib/insights/status-batch.ts",
"lib/insights/status-card-generation.ts",
"lib/insights/metric-status.ts",
"lib/insights/biomarker-status.ts",
"lib/insights/derived/derived-assessment-ai.ts",
"lib/jobs/workout-insight-generate.ts",
];
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** `reasoningJob:` as a value, not the optional declaration `reasoningJob?:`. */
const SETTER = /\breasoningJob\s*:(?!\s*BackgroundReasoningJob)/g;
function scan(): Map<string, { setters: number; jobs: Set<string> }> {
const found = new Map<string, { setters: number; jobs: Set<string> }>();
const files = walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
for (const rel of files) {
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
const setters = code.match(SETTER)?.length ?? 0;
if (setters === 0) continue;
const jobs = new Set<string>();
for (const m of code.matchAll(SETTER)) {
const window = code.slice(m.index, m.index + 160);
for (const job of BACKGROUND_REASONING_JOBS) {
if (window.includes(`"${job}"`)) jobs.add(job);
}
}
found.set(rel, { setters, jobs });
}
return found;
}
describe("background reasoning inventory", () => {
const found = scan();
it("finds the admitted callers (an empty set is a failure, not a pass)", () => {
expect(found.size).toBeGreaterThanOrEqual(2);
});
it("only the admitted files name a job", () => {
expect([...found.keys()].sort()).toEqual(Object.keys(ADMITTED).sort());
});
it.each(Object.entries(ADMITTED))(
"%s names exactly its admitted jobs",
(rel, { jobs }) => {
expect([...(found.get(rel)?.jobs ?? [])].sort()).toEqual(
[...jobs].sort(),
);
},
);
it.each(NEVER)("%s never reasons", (rel) => {
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
expect(code).not.toMatch(/\breasoningJob\b/);
expect(code).not.toMatch(/\breasoning\s*:/);
});
it("every admitted job is a known background job", () => {
for (const { jobs } of Object.values(ADMITTED)) {
for (const job of jobs) {
expect(BACKGROUND_REASONING_JOBS as readonly string[]).toContain(job);
}
}
});
});
@@ -0,0 +1,75 @@
/**
* `backupPayloadSchema` is built when `src/lib/validations/backup.ts` is
* evaluated and holds about 12 MB of heap. The restore job, the stored-backup
* readers and the export writer sit on the boot path, so a static value import
* of that module from any of them puts the schema in every server process,
* whether or not it ever touches a backup. Runtime code reaches it with a
* dynamic `import()` at the moment a file is validated, takes only types
* statically, and takes the version and the summary from `backup-summary.ts`.
*
* The one static importer allowed is the OpenAPI registry, which only the
* spec generator loads.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..");
const ALLOWED_STATIC = new Set(["lib/openapi/routes/export.ts"]);
function sourceFiles(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (name === "__tests__" || name === "generated") continue;
if (statSync(path).isDirectory()) out.push(...sourceFiles(path));
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name)) {
out.push(path);
}
}
return out;
}
// `import … from "@/lib/validations/backup"`, across lines, capturing what
// precedes the specifier. Every importer uses the alias (a relative
// `./backup` also names `src/lib/cycle/backup.ts`, so it is not matched).
const STATIC_IMPORT =
/^import\s+(type\s+)?([^;]*?)\s+from\s+["']@\/lib\/validations\/backup["']/gm;
const DYNAMIC_IMPORT = /import\(\s*["']@\/lib\/validations\/backup["']\s*\)/g;
describe("the backup payload schema loads on first use", () => {
const files = sourceFiles(ROOT);
const staticValueImporters: string[] = [];
let dynamicImports = 0;
let typeImports = 0;
for (const file of files) {
const rel = relative(ROOT, file);
if (rel === "lib/validations/backup.ts") continue;
const text = readFileSync(file, "utf8");
for (const match of text.matchAll(STATIC_IMPORT)) {
const typeOnly =
match[1] !== undefined ||
(/^\{[^}]*\}$/.test(match[2]) &&
match[2]
.slice(1, -1)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.every((s) => s.startsWith("type ")));
if (typeOnly) typeImports++;
else if (!ALLOWED_STATIC.has(rel)) staticValueImporters.push(rel);
}
dynamicImports += [...text.matchAll(DYNAMIC_IMPORT)].length;
}
it("has no static value import outside the OpenAPI registry", () => {
expect(staticValueImporters).toEqual([]);
});
it("finds the loaders it guards (an empty match set would pass vacuously)", () => {
// upload, restore, preview (store + summary route), download, streamed reader
expect(dynamicImports).toBeGreaterThanOrEqual(5);
expect(typeImports).toBeGreaterThan(0);
});
});
@@ -0,0 +1,65 @@
/**
* Structural guard: the runtime image puts the @napi-rs/canvas native binary
* of the version the app pins next to that version's loader.
*
* ## The failure this freezes (GitHub #1124)
*
* The pnpm store holds two canvas versions: the one in package.json and an
* older one that pdf-parse's own pdfjs-dist pulls in. The runner stage picked
* the musl binary with `find | head -1`, got the old one, and copied it into
* the new loader's directory, where the loader looks first. PDF rendering
* still worked, so nothing looked wrong; `loadImage` called an
* `Image.decode` the old binary lacks, and every preview thumbnail failed
* with "image.decode is not a function" on both architectures.
*
* ## What it proves and what it does not
*
* The builder records the resolved canvas version, the runner hoists by that
* name, and the build stops when the copied binary's package is a different
* version. Whether the binary loads is proved when the image runs; this file
* reads the Dockerfile only.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const DOCKERFILE = readFileSync(join(process.cwd(), "Dockerfile"), "utf8");
function stage(name: string): string {
const start = DOCKERFILE.search(new RegExp(`^FROM .* AS ${name}$`, "m"));
expect(start, `the Dockerfile has a ${name} stage`).toBeGreaterThan(0);
const next = DOCKERFILE.slice(start + 1).search(/^FROM /m);
return next < 0
? DOCKERFILE.slice(start)
: DOCKERFILE.slice(start, start + 1 + next);
}
describe("canvas binary hoist", () => {
it("records the resolved canvas version in the builder", () => {
expect(stage("builder")).toMatch(
/require\('\/app\/node_modules\/@napi-rs\/canvas\/package\.json'\)\.version"[\s\\]*>\s*\/app\/\.canvas-version/,
);
});
it("hoists the loader and the musl binary of that version only", () => {
const runner = stage("runner");
expect(runner).toContain(
"COPY --from=builder /app/.canvas-version /tmp/.canvas-version",
);
expect(runner).toContain(
'CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/',
);
expect(runner).toContain("canvas-linux-*-musl@${CANVAS_VERSION}/");
// A version-blind pick is the defect: any `canvas…@*` glob decides by
// readdir order which of the store's copies wins.
expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/);
});
it("fails the build when the copied binary is another version", () => {
const runner = stage("runner");
expect(runner).toMatch(
/\[ "\$BIN_VERSION" = "\$CANVAS_VERSION" \] \|\| \{[^}]*exit 1; \}/,
);
});
});
@@ -0,0 +1,56 @@
/**
* Structural guard on keyboard focus in the Coach panel.
*
* The purple `--ring` is gone from every Coach control: a tap draws nothing,
* keyboard focus draws a solid ring in the neutral text-field focus tone
* (`--input-focus`). Two checks:
*
* 1. No class string under `coach-panel/` names the purple ring, border or
* outline on focus. The sweep must find a floor of neutral focus rings,
* so a matcher that stops matching fails instead of passing on nothing.
* 2. The controls built on the `Button` primitive inside an answer (the
* reply pills, Undo, Change) pass `COACH_FOCUS_RING`, which replaces the
* primitive's purple ring when the classes merge.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const DIR = join(__dirname, "..", "components", "insights", "coach-panel");
const SOURCES = readdirSync(DIR)
.filter((name) => /\.(tsx|ts)$/.test(name))
.map((name) => ({ name, text: readFileSync(join(DIR, name), "utf8") }));
const PURPLE_FOCUS =
/(?:focus|focus-visible|focus-within):(?:ring-ring|border-ring|outline-ring|ring-primary|border-primary)\b/g;
describe("Coach focus ring", () => {
it("never names the purple ring on focus", () => {
const offenders = SOURCES.flatMap(({ name, text }) =>
[...text.matchAll(PURPLE_FOCUS)].map((m) => `${name}: ${m[0]}`),
);
expect(offenders).toEqual([]);
});
it("finds the neutral ring where it expects it", () => {
const neutral = SOURCES.reduce(
(n, { text }) =>
n + (text.match(/focus-visible:ring-input-focus\b/g)?.length ?? 0),
0,
);
expect(neutral).toBeGreaterThanOrEqual(15);
});
it.each(["suggested-replies.tsx", "memory-note.tsx", "assumption-line.tsx"])(
"%s merges the neutral ring over the Button primitive",
(file) => {
const text = SOURCES.find((s) => s.name === file)?.text ?? "";
expect(text).toMatch(
/import \{ COACH_FOCUS_RING \} from "\.\/focus-ring"/,
);
expect(text).toMatch(/cn\([\s\S]*?COACH_FOCUS_RING,?\s*\)/);
},
);
});
@@ -10,7 +10,8 @@
* shape would make the whole frame fail to decode, and a new frame that
* reused one with the same shape would leak a value into whatever the client
* does with that key. So a new frame names its payload with a key of its own
* (`step`, `result`, `followUps`, `clarification`).
* (`step`, `result`, `followUps`, `clarification`, and since v1.41
* `activity`, `memoryNote` with `note`, `planProposal` with `proposal`).
*
* Reads the frame list from the Zod mirror (`stream-events.ts`), which a
* type test holds equal to `CoachStreamEvent`, so a frame added to the
@@ -81,6 +82,9 @@ describe("Coach stream frames stay decodable by the shipped native client", () =
"result",
"followUps",
"clarification",
"activity",
"memoryNote",
"planProposal",
]),
);
expect(
@@ -63,18 +63,26 @@ describe("Coach streaming lifecycle", () => {
"components/insights/coach-panel/coach-conversation.tsx",
);
// Isolate the HistoryRail onSelect callback body.
// One switch handler, shared by the drawer's history tray and the page's
// conversations panel, so neither surface can skip the reset.
const match = source.match(
/<HistoryRail[\s\S]*?onSelect=\{\([\s\S]*?\n\s*\}\}/,
/function selectConversation\(id: string\) \{[\s\S]*?\n {2}\}/,
);
expect(match, "HistoryRail onSelect handler not found").not.toBeNull();
const onSelectBody = match![0];
expect(match, "selectConversation handler not found").not.toBeNull();
expect(
onSelectBody.includes("send.reset()"),
match![0].includes("send.reset()"),
"selecting another conversation must call send.reset() so the previous " +
"thread's trailing stream state does not bleed into the new thread",
).toBe(true);
const selectSites = source.match(/onSelect=\{([^}]*)\}/g) ?? [];
expect(selectSites.length).toBeGreaterThanOrEqual(2);
for (const site of selectSites) {
expect(
site,
"every conversation picker routes through selectConversation",
).toBe("onSelect={selectConversation}");
}
});
it("the unmount cleanup records the interrupted conversation before the abort (#781)", () => {
+149
View File
@@ -0,0 +1,149 @@
/**
* v1.41 — the Coach's voice, held in the prompt.
*
* The voice section (`buildCoachVoiceBlock`) is the one statement of how a
* Coach answer is shaped: the answer first, one carrying number, the
* assumption in one line, at most one next step, 40-120 words, and a closed
* list of filler it never writes. A prompt rewrite that drops a rule, or an
* example that models the opposite (a closing question, two next steps, a
* filler), would quietly teach the model the old habits back. This guard
* pins both bodies to the rules, and every few-shot example to the grader
* that scores live answers.
*
* Each check is a function run once on the real prompt and once on a prompt
* broken on purpose, so the guard is shown to fail.
*/
import { describe, expect, it } from "vitest";
import {
COACH_ANSWER_WORDS,
COACH_FILLER_PHRASES,
buildCoachVoiceBlock,
getCoachSystemPrompt,
} from "@/lib/ai/coach/system-prompt";
import { gradeVoice } from "@/lib/ai/coach/eval/grade-voice";
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
import type { Locale } from "@/lib/i18n/config";
const LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"];
/** The rules a voice section must state; `[]` when it states them all. */
function missingVoiceRules(prompt: string, lang: "de" | "en"): string[] {
const missing: string[] = [];
const band = COACH_ANSWER_WORDS.standard;
const header =
lang === "de" ? "TON UND ANTWORTAUFBAU" : "VOICE AND ANSWER SHAPE";
if (!prompt.includes(header)) missing.push("header");
if (!prompt.includes(`${band.min}-${band.max}`)) missing.push("length band");
const rules =
lang === "de"
? [
/erste Satz beantwortet die Frage/,
/Eine tragende Zahl/,
/Angenommen: letzte/,
/höchstens EINEM konkreten nächsten Schritt/,
/Nie eine Frage am Ende/,
/keine Emojis/,
]
: [
/first sentence answers the question/,
/One carrying number/,
/Assumed: last 30 days/,
/at most ONE concrete next step/,
/Never a question at the end/,
/no emojis/,
];
for (const rule of rules) if (!rule.test(prompt)) missing.push(rule.source);
for (const phrase of COACH_FILLER_PHRASES[lang]) {
if (!prompt.includes(`"${phrase}"`)) missing.push(`filler ${phrase}`);
}
return missing;
}
/** The COACH lines of the few-shot examples. */
function exampleAnswers(prompt: string): string[] {
const answers: string[] = [];
for (const match of prompt.matchAll(/<example>([\s\S]*?)<\/example>/g)) {
const coach = match[1].split(/^COACH:\s*/m)[1];
if (coach) answers.push(coach.trim());
}
return answers;
}
/** Examples that break the voice; `[]` when every one keeps it. */
function examplesOffVoice(prompt: string): string[] {
return exampleAnswers(prompt).filter((answer) => !gradeVoice(answer).passed);
}
describe("coach voice guard", () => {
it.each(["en", "de"] as const)(
"the %s body states every voice rule and filler",
(lang) => {
const prompt = getCoachSystemPrompt(lang);
expect(missingVoiceRules(prompt, lang)).toEqual([]);
// Broken on purpose: one filler dropped from the list.
const broken = prompt.replaceAll(
`"${COACH_FILLER_PHRASES[lang][0]}"`,
"",
);
expect(missingVoiceRules(broken, lang)).not.toEqual([]);
},
);
it.each(LOCALES)("the %s prompt carries the voice section", (locale) => {
expect(getCoachSystemPrompt(locale)).toContain(
buildCoachVoiceBlock(locale),
);
});
it.each(["en", "de"] as const)(
"every %s example answer keeps the voice",
(lang) => {
const prompt = getCoachSystemPrompt(lang);
expect(exampleAnswers(prompt).length).toBeGreaterThanOrEqual(5);
expect(examplesOffVoice(prompt)).toEqual([]);
// Broken on purpose: an example that ends on a habitual question.
const broken = prompt.replace(
/(<example>[\s\S]*?COACH:[\s\S]*?)(\n(?:---KEYVALUES---|<\/example>))/,
"$1 How does that sound to you?$2",
);
expect(examplesOffVoice(broken)).not.toEqual([]);
},
);
it("keeps no length rule from before the voice section", () => {
for (const lang of ["en", "de"] as const) {
const prompt = getCoachSystemPrompt(lang, {
...DEFAULT_COACH_PREFS,
verbosity: "detailed",
});
expect(prompt).not.toMatch(/60-180/);
expect(prompt).not.toMatch(/180-250/);
expect(prompt).not.toMatch(/~90/);
}
});
it("states the brief and detailed lengths from the same numbers", () => {
const brief = getCoachSystemPrompt("en", {
...DEFAULT_COACH_PREFS,
verbosity: "brief",
});
expect(brief).toContain(
`${COACH_ANSWER_WORDS.brief.min}-${COACH_ANSWER_WORDS.brief.max} words`,
);
const detailed = getCoachSystemPrompt("de", {
...DEFAULT_COACH_PREFS,
verbosity: "detailed",
});
expect(detailed).toContain(`bis ${COACH_ANSWER_WORDS.detailed.max} Wörter`);
});
it("asks a confidence question only on an explicit plan request", () => {
expect(getCoachSystemPrompt("en")).toMatch(
/Confidence ruler, only on an explicit plan request/,
);
expect(getCoachSystemPrompt("de")).toMatch(
/Konfidenz-Skala, nur bei ausdrücklicher Plan-Bitte/,
);
});
});
@@ -102,6 +102,8 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly<Record<string, string>> = {
// --- Baked at build time by the Dockerfile's ARG/ENV pairs. ---
NEXT_PUBLIC_APP_VERSION:
"Dockerfile build arg; a runtime value cannot change what is compiled in",
NEXT_PUBLIC_I18N_CATALOG_VERSION:
"computed by next.config.ts from the message catalogs at build time",
NEXT_PUBLIC_APP_BUILD_SHA:
"Dockerfile build arg, stamped from the release commit",
NEXT_PUBLIC_APP_BUILT_AT: "Dockerfile build arg, stamped at image build",
@@ -235,6 +235,10 @@ function scan(): Scan {
* would make the key move when the preference did.
*/
const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
{
file: "src/components/timeline/timeline-dates.ts",
why: "`todayKeyIn` asks `en-CA` for today's `YYYY-MM-DD` key in the record's zone, to compare with the timeline's calendar keys. Never rendered; every label there is a month name.",
},
{
file: "src/app/api/measurement-reminders/[id]/snooze/route.ts",
why: "An ISO day probe split back into numbers to do day arithmetic on the postpone target. Never rendered.",
@@ -81,6 +81,8 @@ const DENSE_TYPES: ReadonlySet<string> = new Set([
* list, keyed `<file>::<type expression>`, with why each stays bounded.
*/
const ALLOWED: Record<string, string> = {
"lib/analytics/summaries-slice.ts::(shorthand)":
"one hourly-mean type (pulse) over one local day, the latest: the same rows the day view reads for that day, bounded by a day, not by the sampling history",
"lib/insights/comprehensive-generate.ts::(shorthand)":
'Only reached for SLEEP_DURATION (guarded by `type === "SLEEP_DURATION"`); a few stage rows per night.',
"lib/insights/derived/vascular-age.ts::VASCULAR_AGE_TYPE":
@@ -107,6 +109,8 @@ const ALLOWED: Record<string, string> = {
"Cross-source merge probe for one posted sleep segment: SLEEP_DURATION in a ±tolerance window around its end.",
"lib/targets/build-response.ts::{ in: recentTypes":
"Thirty days of weight, blood pressure, resting heart rate, body fat and steps (drained to one row per day). PULSE is filtered out of `recentTypes` and folds in SQL.",
"lib/import/health-connect/import.ts::{ in: spotTypes":
"The Health Connect import's same-reading check: the spot types of one 500-row batch (weight, blood pressure, vitals) in that batch's time window, ±2 s. Heart rate and sleep never take the check.",
"lib/doctor-report/collect.ts::{ notIn: rawExcluded":
"The report window's sparse types. Pulse, heart-rate variability and blood oxygen over 10 000 readings or any window over 90 days, and glucose over 90 days, are in `rawExcluded` and read as day buckets; glucose inside 90 days stays raw for the clinical panel (per-reading metrics), below 26 000 rows at a sensor's rate.",
};
@@ -123,6 +127,8 @@ const ALLOWED_SQL: Record<string, string> = {
"Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.",
"lib/measurements/reconcile-external-measurement.ts::1":
"Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.",
"lib/jobs/pr-detection.ts::1":
"Selects account ids; `measurements` is read inside `EXISTS`, one row per account at most.",
"lib/rollups/measurement-coverage.ts::1":
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
};
@@ -972,7 +972,15 @@ describe("derived response consumer guard", () => {
`derived apiSuccess branch ${index + 1} must preserve producer lineage`,
).toEqual(expectedLineage);
}
expect(variableCall(ROUTE_FILE, "derived")).toBe("computeDerivedMetric");
// v1.42 — the single read goes through the derived cache; the value is
// still the compute's, built inside the cache's builder.
const derivedProducer = variableCall(ROUTE_FILE, "derived");
expect(["computeDerivedMetric", "cachedSwr"]).toContain(derivedProducer);
if (derivedProducer === "cachedSwr") {
expect(readFileSync(ROUTE_FILE, "utf8")).toMatch(
/const derived = await cachedSwr\([\s\S]*?computeDerivedMetric\(/,
);
}
expect(variableCall(ROUTE_FILE, "assessment")).toBe(
"resolveDerivedAssessment",
);
@@ -112,4 +112,29 @@ describe("published WellnessScoreValue", () => {
Object.keys(derived.value as unknown as Record<string, unknown>).sort(),
);
});
it("accepts the strain value served from the device's day strain", async () => {
findMany.mockImplementation(async (args: { where: { type: string } }) =>
args.where.type === "DAY_STRAIN"
? [
{
value: 12.6,
measuredAt: new Date("2026-06-01T04:00:00Z"),
source: "WHOOP",
},
]
: [],
);
const derived = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, {
now: NOW,
});
expect(derived.status).toBe("ok");
if (derived.status !== "ok") return;
const parsed = wellnessScoreValue.safeParse(derived.value);
expect(parsed.error?.issues ?? []).toEqual([]);
expect(Object.keys(wellnessScoreValue.shape).sort()).toEqual(
Object.keys(derived.value as unknown as Record<string, unknown>).sort(),
);
findMany.mockReset();
});
});
@@ -196,9 +196,26 @@ const REGISTRY: DestructiveEntry[] = [
},
{
file: "components/settings/coach-memory-section.tsx",
destroys: "one remembered Coach fact, or every one of them",
destroys: "every remembered Coach fact at once",
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton", "AlertDialog"],
confirm: ["AlertDialog"],
},
{
file: "components/insights/coach-panel/use-coach-facts.ts",
destroys: "one remembered Coach fact",
triggers: [
"components/settings/coach-memory-section.tsx",
"components/insights/coach-panel/memory-note.tsx",
],
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
unconfirmed: [
{
control: "Undo on the memory note under an answer — memory-note.tsx",
reason:
"Undo takes back what the Coach saved a moment ago on its own, from the person's own words in the same turn. Nothing the person wrote is lost: their message stays in the thread and the fact can be kept again with one tap. Asking to confirm an undo would make reversing the Coach dearer than letting it keep something unwanted. Forgetting from the memory list, where a fact may be old and relied on, is confirmed.",
},
],
},
{
file: "hooks/use-coach-reminders.ts",
@@ -210,16 +227,12 @@ const REGISTRY: DestructiveEntry[] = [
{
file: "hooks/use-coach-plans.ts",
destroys: "a Coach plan and the record of how it went",
triggers: [
"app/coach/plans/page.tsx",
"components/insights/coach-panel/plan-proposal-card.tsx",
],
triggers: ["app/coach/plans/page.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
unconfirmed: [
{
control:
"decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx",
control: "decline a PROPOSED plan — app/coach/plans/page.tsx",
reason:
"Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.",
},
@@ -426,6 +439,15 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["AlertDialog"],
},
{
file: "components/timeline/use-timeline.ts",
destroys: "one life event with its encrypted title and note",
triggers: ["components/timeline/life-event-form.tsx"],
// The route soft-deletes; there is no restore route or surface yet.
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
},
// ── Cycle ───────────────────────────────────────────────────────────────
{
file: "components/cycle/use-cycle.ts",
@@ -0,0 +1,161 @@
/**
* v1.42 (#615) — pair guard binding the two environment models in
* `prisma/schema.prisma` to the backup that carries them
* (`src/lib/export/environment-backup.ts`), on the pattern of
* `measurement-backup-completeness.test.ts`.
*
* Twenty-two air-quality columns and three sealed locations arrived in one
* release, and nothing failed when a column was missing from the backup: the
* export kept building, the restore kept parsing (the section schema is
* `.passthrough()`), and the column came back NULL on every restored day. So
* both ends are compared as literals:
*
* - every scalar column of `EnvironmentContext` and
* `EnvironmentTravelLocation` is in the named backup select (or excluded
* here with a reason);
* - every selected `EnvironmentContext` column is written by the export
* (`row.<column>`, or the location helper for the location columns);
* - every selected column is written back by the restore (`<column>:` in
* `restoreEnvironmentData`, or listed in `AIR_QUALITY_COLUMNS`, which the
* restore maps over).
*
* Mutation check: delete `pm25Mean: true` from the context select and the
* first case names it; delete `"pm25Mean",` from `AIR_QUALITY_COLUMNS` and the
* restore case names it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const schema = readFileSync(join(ROOT, "prisma", "schema.prisma"), "utf8");
const source = readFileSync(
join(ROOT, "src", "lib", "export", "environment-backup.ts"),
"utf8",
);
/** Columns the backup is right to leave out, with the reason. */
const EXCLUDED: Record<string, string> = {
id: "nothing in the file or the database addresses one of these rows; a day is identified by its date",
userId:
"user-scoped backup; the owner is re-derived from the restoring account",
};
/** Columns written through the location helper rather than `row.<column>`. */
const LOCATION_COLUMNS = new Set([
"lat",
"lon",
"locationLabel",
"label",
"locationEncrypted",
]);
function block(text: string, start: string, end = "\n}"): string {
const from = text.indexOf(start);
expect(from, `${start} not found`).toBeGreaterThan(-1);
const to = text.indexOf(end, from);
expect(to).toBeGreaterThan(from);
return text.slice(from, to);
}
function scalarColumns(model: string): string[] {
const models = new Set(
[...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]),
);
const out: string[] = [];
for (const raw of block(schema, `model ${model} {`).split("\n").slice(1)) {
const line = raw.trim();
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?/.exec(line);
if (!m || m[3] || models.has(m[2]) || line.includes("@relation")) continue;
out.push(m[1]);
}
return out;
}
function selectKeys(name: string): string[] {
return [
...block(source, `const ${name} = {`).matchAll(/^ {2}(\w+): true,$/gm),
].map((m) => m[1]);
}
const restoreBody = block(
source,
"export async function restoreEnvironmentData(",
"\n}\n",
);
const airQualityList = block(source, "const AIR_QUALITY_COLUMNS = [", "]");
describe.each([
["EnvironmentContext", "ENVIRONMENT_CONTEXT_BACKUP_SELECT"],
["EnvironmentTravelLocation", "ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT"],
])("%s backup completeness", (model, selectName) => {
const columns = scalarColumns(model);
const selected = selectKeys(selectName);
it("parses both ends into a plausible shape", () => {
expect(columns.length).toBeGreaterThan(8);
expect(columns).toContain("locationEncrypted");
expect(selected.length).toBeGreaterThan(5);
});
it("the backup select covers every column", () => {
const missing = columns.filter(
(c) => !selected.includes(c) && !(c in EXCLUDED),
);
expect(
missing,
`${selectName} omits these ${model} columns; a restore brings them back NULL`,
).toEqual([]);
});
it("the restore writes every selected column back", () => {
const unwritten = selected.filter(
(c) =>
!new RegExp(`\\b${c}:`).test(restoreBody) &&
!airQualityList.includes(`"${c}"`) &&
// The day key and the source are written under their own names.
!["date", "startDate", "endDate"].includes(c),
);
expect(unwritten, "selected but never restored").toEqual([]);
});
});
describe("EnvironmentContext export", () => {
it("writes every selected column into the entry", () => {
const selected = selectKeys("ENVIRONMENT_CONTEXT_BACKUP_SELECT");
const unwritten = selected.filter(
(c) =>
!LOCATION_COLUMNS.has(c) && !new RegExp(`row\\.${c}\\b`).test(source),
);
expect(unwritten, "selected but never exported").toEqual([]);
});
it("covers all twenty-two air-quality columns and the sealed location", () => {
const selected = selectKeys("ENVIRONMENT_CONTEXT_BACKUP_SELECT");
for (const column of [
"apparentMax",
"pm25Mean",
"o3Max8h",
"pollenRagweedMax",
"aqDomain",
"aqHours",
"aqFetchedAt",
"locationEncrypted",
]) {
expect(selected, column).toContain(column);
}
});
it("carries no stale exclusion", () => {
const all = new Set([
...scalarColumns("EnvironmentContext"),
...scalarColumns("EnvironmentTravelLocation"),
]);
for (const [column, reason] of Object.entries(EXCLUDED)) {
expect(all.has(column), column).toBe(true);
expect(reason.length).toBeGreaterThan(20);
}
});
});
@@ -0,0 +1,327 @@
/**
* A caught error in a background job is either reported or explained.
*
* The failures that cost the most in this codebase were never thrown at
* anyone: a nightly pass that caught its error, annotated an `info` line and
* carried on, completed in pg-boss every night while doing nothing. The
* briefing warm read "all-failed" for a week before anyone could tell which
* account and why; the environment fetch threw on every run for the life of
* the module. A `catch` that drops what it caught is where that starts.
*
* Scope: every `catch` clause and every `.catch(handler)` under
* `src/lib/jobs/` (tests excluded). A catch passes when its body does one of:
*
* - rethrows, rejects, or hands the error to a stream callback;
* - fails the job (`jobFailed`);
* - logs at warn or error: `logCaught` / `caughtAs`, `emitSignal`,
* `reportWorkerError`,
* `addWarning`, `setError`, `elevateLevel`, `workerLog("error", …)`,
* `console.error` / `console.warn`;
* - writes the failure to a ledger an operator surface reads
* (`recordSyncFailure`, `failAiRun`, `markFailed`,
* `recordThumbnailFailure`, `recordBriefingFailure`);
* - counts it into a failure tally (`failed += 1`, `errored++`,
* `failedSubjects.set(…)`) that the job reports in its outcome facts, so a
* systemic failure shows as a count and a partial run as
* `job.run.partial`;
* - hands it back to its caller in the result (`error: err…`), which is
* how every boot-time discovery enqueue reports to the registrar that
* logs it at error.
*
* Anything else is listed in `ALLOWED` below with the reason it may stay
* silent, keyed by file and enclosing function, with how many such catches
* that function holds. The reasons are the review: "best effort" is not one;
* "the pgboss schema is absent on a web-only deployment and null is the
* documented answer" is.
*
* The list cannot rot quietly in either direction. A new silent catch fails
* the second case. An entry whose catches were fixed or removed fails the
* third case (stale entry). And the fourth case removes each entry in turn
* and asserts the sweep goes red without it, so every entry is shown to be
* load-bearing, and the sweep is shown to find what it claims to find.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const SCOPE = "lib/jobs/";
const REPORTS =
/\bthrow\b|\breject\(|\bcallback\(\s*err|\bjobFailed\(|\blogCaught\(|\bcaughtAs\(|\bemitSignal\(|\breportWorkerError\(|\.addWarning\(|\.setError\(|\belevateLevel\(|\bworkerLog\(\s*["']error|\bconsole\.(error|warn)\(|\brecordSyncFailure\(|\bfailAiRun\(|\bmarkFailed\(|\brecordThumbnailFailure\(|\brecordBriefingFailure\(|\berror:\s*err\b/;
/** A failure tally: `summary.failed += 1`, `errored++`, `failures += 1`. */
const COUNTS =
/\b[\w.]*(?:failed|failures|errored|Failed|Failures)\w*\s*(?:\+=|\+\+|\.set\(|\.push\()/;
interface Finding {
/** `<file under src>::<enclosing function>` */
key: string;
line: number;
body: string;
}
function scan(file: string, text: string): Finding[] {
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
const out: Finding[] = [];
const visit = (node: ts.Node, fn: string): void => {
let name = fn;
if (
(ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) &&
node.name
) {
name = node.name.getText(sf);
} else if (
ts.isVariableDeclaration(node) &&
node.initializer &&
(ts.isArrowFunction(node.initializer) ||
ts.isFunctionExpression(node.initializer))
) {
name = node.name.getText(sf);
}
let body: string | null = null;
if (ts.isCatchClause(node)) body = node.block.getText(sf);
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.getText(sf) === "catch" &&
node.arguments[0]
) {
body = node.arguments[0].getText(sf);
}
if (body !== null && !REPORTS.test(body) && !COUNTS.test(body)) {
out.push({
key: `${file}::${name}`,
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
body,
});
}
ts.forEachChild(node, (child) => visit(child, name));
};
visit(sf, "<module>");
return out;
}
/**
* Catches allowed to stay silent, by file and function: how many, and why.
* Keep the reason checkable against the code it names.
*/
const ALLOWED: Readonly<Record<string, { count: number; reason: string }>> = {
"lib/jobs/apple-health-import-worker.ts::reconcileOrphanImportJobs": {
count: 1,
reason:
"A failed live-state lookup reads as live on purpose: the heartbeat is fresh, flipping a running import would kill it, and the next boot re-evaluates once the heartbeat goes stale.",
},
"lib/jobs/apple-health-import-worker.ts::safeUnlink": {
count: 1,
reason:
"Deleting a staged file that a previous step or sweep already removed; ENOENT is the expected case and nothing is lost either way.",
},
"lib/jobs/backup-restore.ts::writeProgress": {
count: 1,
reason:
"A progress tick for the status poll; the next tick rewrites it and the job's own outcome is recorded by `finish`.",
},
"lib/jobs/backup-restore.ts::runBackupRestoreJob": {
count: 1,
reason:
"Waits out the in-flight progress write before the final state is written; that write's failure is the progress tick's, see writeProgress.",
},
"lib/jobs/backup-restore.ts::sweepInterruptedRestores": {
count: 1,
reason:
"Completing the dead worker's superseded pg-boss delivery; if it fails the delivery expires on its own, and the restore row it belonged to is already failed and counted.",
},
"lib/jobs/boss-instance.ts::startProducerAttempts": {
count: 2,
reason:
"The start error is kept and thrown after the last attempt; stopping a boss that never started can only fail for the same reason.",
},
"lib/jobs/boss-instance.ts::superviseProducerReconnect": {
count: 1,
reason:
"Stopping the producer whose connection already failed, before reconnecting; the reconnect loop reports its own failures.",
},
"lib/jobs/free-text-encryption-backfill.ts::scrubContactAuditDetails": {
count: 1,
reason:
"A parser: details that are not this action's JSON are left alone by contract (null means 'nothing to scrub'), not an error.",
},
"lib/jobs/geo-backfill.ts::runGeoBackfill": {
count: 1,
reason:
"The row was deleted by the retention sweep between read and update; counted as still unresolved, and the next pass no longer sees it.",
},
"lib/jobs/host-metric-sampler.ts::captureHostMetric": {
count: 1,
reason:
"Disk counters exist only on Linux; elsewhere the sample records null for them by design.",
},
"lib/jobs/host-metric-sampler.ts::readLinuxDiskStats": {
count: 1,
reason: "No /proc/diskstats outside Linux; see captureHostMetric.",
},
"lib/jobs/job-failures.ts::readActiveJobsStartedBefore": {
count: 1,
reason:
"No pgboss schema on a web-only deployment; null is the documented 'no queue to ask', kept apart from an empty list.",
},
"lib/jobs/job-failures.ts::readConsecutiveRunFailures": {
count: 1,
reason:
"Called from a failure path that is already logging at error; when the queue cannot be read, 1 (this run) is the only known count.",
},
"lib/jobs/job-failures.ts::readFailingQueues": {
count: 1,
reason: "See readActiveJobsStartedBefore: null means no queue schema.",
},
"lib/jobs/job-failures.ts::readLastQueueRun": {
count: 1,
reason: "See readActiveJobsStartedBefore: null means no queue schema.",
},
"lib/jobs/job-failures.ts::readQueueFailureForUser": {
count: 1,
reason: "See readActiveJobsStartedBefore: null means no queue schema.",
},
"lib/jobs/job-failures.ts::readQueueRunningSince": {
count: 1,
reason: "See readActiveJobsStartedBefore: null means no queue schema.",
},
"lib/jobs/measurement-maintenance-status.ts::readMeasurementMaintenanceStatus":
{
count: 1,
reason:
"No pgboss schema on a web-only deployment; the admin card reads `available: false` and says so.",
},
"lib/jobs/measurement-maintenance-status.ts::readSizes": {
count: 1,
reason:
"A size probe for an admin card: null shows no figure, and the status read must not fail over it.",
},
"lib/jobs/job-observer.ts::emitJobLine": {
count: 1,
reason:
"The catch around writing a log line itself; there is nowhere further to log to, and a log line must not break the job.",
},
"lib/jobs/offhost-backup.ts::getS3Client": {
count: 2,
reason:
"headObject answers 'absent' for a missing object (its contract), and the lifecycle read maps NoSuchLifecycleConfiguration to 'missing' and anything else to 'unknown', which the status card shows.",
},
"lib/jobs/offhost-backup.ts::loadOffhostConfigSafe": {
count: 1,
reason:
"A malformed key reads as 'not configured' for the status card; the backup pass itself calls the throwing loader and fails loudly.",
},
"lib/jobs/offhost-backup.ts::probeOffhostLifecycle": {
count: 1,
reason:
"The lifecycle probe answers 'unknown', which the admin card renders as such; it is a display read, not a pass.",
},
"lib/jobs/offhost-backup.ts::runOffhostRoundtripTest": {
count: 2,
reason:
"The admin's connection test returns its error in the result it shows (ok: false, error), and deleting its own probe object is cleanup.",
},
"lib/jobs/offhost-backup.ts::withEachKey": {
count: 1,
reason:
"Trying each key of the ring in turn; the last error is thrown when no key opens the object.",
},
"lib/jobs/reminder/medication-reminder-check.ts::cleanupScheduledTelegramDeletions":
{
count: 1,
reason:
"Deleting a reminder message the person usually already deleted; the scheduled row is removed either way, and logging every expected 400 would bury the real ones.",
},
"lib/jobs/reminder/polar-sync.ts::syncUserPolarLegs": {
count: 2,
reason:
"Both legs run, the first error is kept and thrown after the second leg.",
},
"lib/jobs/restore-drill.ts::runRestoreDrill": {
count: 1,
reason:
"The per-account drill failure is recorded on the account's result (ok: false, error) and counted into the run's restore_drill_accounts_failed fact.",
},
};
function sweep(): Finding[] {
return walkSourceFiles(join(SRC, SCOPE), { floor: 100 })
.filter((p) => !p.includes("__tests__") && !p.endsWith(".test.ts"))
.flatMap((p) =>
scan(`${SCOPE}${p}`, readFileSync(join(SRC, SCOPE, p), "utf8")),
);
}
function unallowed(
findings: readonly Finding[],
allowed: Readonly<Record<string, { count: number }>>,
): string[] {
const byKey = new Map<string, Finding[]>();
for (const f of findings) {
byKey.set(f.key, [...(byKey.get(f.key) ?? []), f]);
}
const out: string[] = [];
for (const [key, list] of byKey) {
const allowance = allowed[key]?.count ?? 0;
if (list.length > allowance) {
for (const f of list.slice(allowance)) {
out.push(
`${key} (line ${f.line}): ${f.body.replace(/\s+/g, " ").slice(0, 120)}`,
);
}
}
}
return out.sort();
}
describe("error-level discipline in background jobs", () => {
it("the scanner flags a silent catch and passes a reported one", () => {
const planted = scan(
"lib/jobs/planted.ts",
[
"async function quiet() { try { await a(); } catch { /* best effort */ } }",
"async function info() { try { await a(); } catch (e) { annotate({ action: { name: 'x.failed' } }); } }",
"async function chained() { await a().catch(() => {}); }",
"async function loud() { try { await a(); } catch (e) { logCaught('x.y.failed', e); } }",
"async function counted() { try { await a(); } catch { summary.failed += 1; } }",
"async function rethrown() { try { await a(); } catch (e) { throw e; } }",
].join("\n"),
);
expect(planted.map((f) => f.key)).toEqual([
"lib/jobs/planted.ts::quiet",
"lib/jobs/planted.ts::info",
"lib/jobs/planted.ts::chained",
]);
});
it("every silent catch in src/lib/jobs is fixed or explained", () => {
expect(unallowed(sweep(), ALLOWED)).toEqual([]);
});
it("no allow-list entry outlives the catches it explains", () => {
const counts = new Map<string, number>();
for (const f of sweep()) counts.set(f.key, (counts.get(f.key) ?? 0) + 1);
const stale = Object.entries(ALLOWED)
.filter(([key, { count }]) => (counts.get(key) ?? 0) < count)
.map(
([key, { count }]) =>
`${key}: allows ${count}, finds ${counts.get(key) ?? 0}`,
);
expect(stale).toEqual([]);
});
it("every allow-list entry is load-bearing: removing it turns the sweep red", () => {
const findings = sweep();
const notLoadBearing = Object.keys(ALLOWED).filter((key) => {
const without = { ...ALLOWED };
delete (without as Record<string, unknown>)[key];
return unallowed(findings, without).length === 0;
});
expect(notLoadBearing).toEqual([]);
});
});
+2 -2
View File
@@ -83,8 +83,8 @@ const NO_FLOOR_NEEDED: Readonly<Record<string, string>> = {
"unit test of the ownership helper",
// List a temporary directory the test itself filled.
"lib/import/__tests__/apple-health-staging.test.ts":
"lists its own temp staging directory",
"lib/import/__tests__/unzip-health-connect.test.ts":
"lists the temp directory to show a refused extraction left no file behind",
"lib/multipart/__tests__/stream-to-disk.test.ts":
"lists its own temp directory",
};
@@ -0,0 +1,31 @@
/**
* The locale catalog's boot script is cached by its URL: the service worker
* serves `/i18n/<locale>.js?v=…` cache-first. Keyed by the package version,
* every build of one release shared one URL, so a beta updated within a
* release kept its first catalog and every key added since read as its raw
* name ("timeline.zoom.range"). The key is the catalogs' content hash now.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8");
describe("the locale catalog's cache key", () => {
it("moves with the catalogs, not with the release", () => {
const layout = read("src/app/layout.tsx");
const src =
/src=\{`\/i18n\/\$\{initialLocale\}\.js\?v=\$\{([^}]*)\}`\}/.exec(layout);
expect(src?.[1]).toContain("NEXT_PUBLIC_I18N_CATALOG_VERSION");
expect(src?.[1]).not.toContain("NEXT_PUBLIC_APP_VERSION");
});
it("is computed from the message catalogs at build time", () => {
const config = read("next.config.ts");
expect(config).toMatch(
/NEXT_PUBLIC_I18N_CATALOG_VERSION:\s*catalogVersion\(\)/,
);
expect(config).toMatch(/join\(process\.cwd\(\), "messages"\)/);
});
});
@@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set<string>([
"insights.derived.composite.READINESS.component.hrv",
"labs.catalog.hs-crp",
"measurements.typeHrvRmssd",
"settings.sections.sources.metrics.hrvRmssd",
"measurements.typeVo2Max",
// "{hours} h {minutes} min": the SI symbols for hour and minute.
"measurements.durationHoursMinutes",
@@ -112,6 +113,7 @@ const LEGIT_IDENTICAL = new Set<string>([
"admin.webPushVapidTitle",
"measurements.sourceAppleHealth",
"measurements.sourceGoogleHealth",
"measurements.sourceHealthConnect",
"medications.sourceAppleHealth",
"settings.ai.providerChain.types.anthropic",
"settings.ai.providerChain.types.codex",
@@ -119,6 +121,7 @@ const LEGIT_IDENTICAL = new Set<string>([
"settings.sections.export.import.appleHealth.title",
"settings.sections.sources.sourceLabels.APPLE_HEALTH",
"settings.sections.sources.sourceLabels.GOOGLE_HEALTH",
"settings.sections.sources.sourceLabels.HEALTH_CONNECT",
"settings.appleHealth.title",
"mood.tag.fastFood",
// Technical / config labels conventionally kept verbatim.
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
/**
* No middle-dot or dash separator in visible strings.
*
* A value like "Added {date} · {size}" or "Weight · body fat · muscle" reads
* as a list of fragments rather than as the app's plain voice, and so does
* "{what} — tomorrow" or "Could not save — try again": the dash stands in
* for a comma, a colon or a full stop the sentence should have said. Facts
* are joined with a comma, a colon or a real phrase instead. This guard
* walks every bundle under `messages/` and fails on any string that contains
* a space, then U+00B7 (middle dot), U+2014 (em dash) or U+2013 (en dash),
* then a space. A middle dot inside a unit ("mL/(kg·min)") and a range
* ("70–100 mmHg", "{start}–{end}") have no surrounding spaces and are not
* matched: an unspaced en dash is how a range is written, not a pause.
*
* A key that genuinely needs the pattern goes into `ALLOWED` with the reason
* written next to it. An allowed key that no longer contains the pattern
* fails too, so the list cannot go stale.
*/
const MESSAGES_DIR = join(__dirname, "../../messages");
const SEPARATORS = [" · ", " — ", " – "] as const;
const hasSeparator = (value: string) =>
SEPARATORS.some((separator) => value.includes(separator));
/** Key path -> why the separator is right there. Empty by intent. */
const ALLOWED: Record<string, string> = {};
/**
* Subtrees whose wording is not ours to change. The questionnaire items are
* the licensed, validated text of PHQ-9, GAD-7 and the other instruments in
* each language; a dash there is part of the instrument (PHQ-9 item 6 reads
* "Feeling bad about yourself — or that you are a failure ..."), and
* rewording it would change what the score measures.
*/
const EXEMPT_PREFIXES: Record<string, string> = {
"mentalHealth.items.": "validated questionnaire wording, verbatim",
};
const isExempt = (key: string) =>
Object.keys(EXEMPT_PREFIXES).some((prefix) => key.startsWith(prefix));
function flatten(
obj: unknown,
prefix: string,
out: Map<string, string>,
): Map<string, string> {
if (obj == null || typeof obj !== "object") return out;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") out.set(key, v);
else if (typeof v === "object") flatten(v, key, out);
}
return out;
}
const bundles = readdirSync(MESSAGES_DIR)
.filter((file) => file.endsWith(".json"))
.sort();
describe("message bundles carry no ' · ', ' — ' or ' – ' separator", () => {
it("finds the bundles", () => {
expect(bundles.length).toBeGreaterThanOrEqual(7);
});
for (const file of bundles) {
const values = flatten(
JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf8")),
"",
new Map(),
);
it(`${file} walks a non-empty set of strings`, () => {
expect(values.size).toBeGreaterThan(1000);
});
it(`${file} has no dot or dash separator outside the allowlist`, () => {
const offenders = [...values]
.filter(
([key, value]) =>
hasSeparator(value) && !(key in ALLOWED) && !isExempt(key),
)
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(offenders).toEqual([]);
});
it(`${file} has no stale allowlist entry`, () => {
const stale = Object.keys(ALLOWED).filter(
(key) => !hasSeparator(values.get(key) ?? ""),
);
expect(stale).toEqual([]);
});
}
});
+34 -2
View File
@@ -66,6 +66,19 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
"apiErrors",
] as const;
/**
* Keys a release lands ahead of their call sites, so that the packages built
* in parallel on the release branch share one bundle and never edit it
* themselves. Each entry names the package that wires the call sites and the
* issue it serves.
*
* This list expires by construction: the companion test fails as soon as an
* entry no longer covers a single orphan, so a prefix whose call sites have
* all landed must be dropped in the same change, and the list is empty again
* before the release is tagged. It is NOT a place for keys nobody will wire.
*/
const RELEASE_PENDING_PREFIXES: Record<string, string> = {};
/**
* Interpolating strings that LOOK like key templates and are not. Each entry
* whitelists nothing — the template still contributes no reachable keys — it
@@ -118,9 +131,14 @@ function nodePaths(obj: unknown, prefix: string, out: Set<string>): void {
}
}
function underPrefix(leaf: string, prefix: string): boolean {
return leaf === prefix || leaf.startsWith(`${prefix}.`);
}
function isAllowlisted(leaf: string): boolean {
return DYNAMIC_ALLOWLIST_PREFIXES.some(
(p) => leaf === p || leaf.startsWith(`${p}.`),
return (
DYNAMIC_ALLOWLIST_PREFIXES.some((p) => underPrefix(leaf, p)) ||
Object.keys(RELEASE_PENDING_PREFIXES).some((p) => underPrefix(leaf, p))
);
}
@@ -185,4 +203,18 @@ describe("i18n reverse coverage", () => {
);
}
}, 15_000);
it("every release-pending prefix still covers an unwired key", () => {
const { leaves, ref } = scan();
const stale = Object.keys(RELEASE_PENDING_PREFIXES).filter(
(prefix) =>
!leaves.some(
(leaf) => underPrefix(leaf, prefix) && !isReferenced(leaf, ref),
),
);
expect(
stale,
"These RELEASE_PENDING_PREFIXES entries have all their call sites now — drop them",
).toEqual([]);
}, 15_000);
});
@@ -0,0 +1,105 @@
/**
* No queue may forget its failed jobs before the failure readers look.
*
* `job-failures.ts` reads pg-boss's own `state = 'failed'` rows over a 72-hour
* window: the admin status card, the per-account "last run failed" hint and
* the repeated-failure count behind the `job.run.failed` alert all rest on
* those rows still existing. pg-boss deletes a terminal row
* `deleteAfterSeconds` after it finished. v1.42 shortens that option on the
* pure-volume queues, and a value under the reader window would make a
* failing queue read as healthy, with nothing anywhere saying so.
*
* The rule: a `deleteAfterSeconds` in the source is either produced by
* `failureReaderRetention(hours)` (which throws under the floor) or written as
* a plain literal at or above `FAILED_ROW_AVAILABILITY_FLOOR_HOURS`. Anything
* else, a named constant included, fails here, because a constant's value is
* not checkable from the call site and the helper exists to make it so.
*
* Mutation check, run when this guard was written: a planted
* `deleteAfterSeconds: 48 * 60 * 60` in a job file fails the second case, and
* `deleteAfterSeconds: VOLUME_SECONDS` fails it too; the matcher self-test
* below proves the scan sees both shapes, so an empty sweep is not mistaken
* for a clean one.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
import { FAILED_ROW_AVAILABILITY_FLOOR_HOURS } from "@/lib/jobs/job-failures";
const SRC = join(process.cwd(), "src");
const FLOOR_SECONDS = FAILED_ROW_AVAILABILITY_FLOOR_HOURS * 3600;
/** The one file allowed to spell the option out: the helper itself. */
const HELPER_FILE = "lib/jobs/job-failures.ts";
const OPTION = /\bdeleteAfterSeconds\s*:\s*([^,}\n]+)/g;
/** Evaluate a sum of products of integer literals; null for anything else. */
function literalSeconds(expression: string): number | null {
const cleaned = expression.trim().replace(/_/g, "");
if (!/^\d+(\s*[*+]\s*\d+)*$/.test(cleaned)) return null;
return cleaned
.split("+")
.map((term) =>
term
.split("*")
.map((factor) => Number(factor.trim()))
.reduce((a, b) => a * b, 1),
)
.reduce((a, b) => a + b, 0);
}
interface Finding {
file: string;
expression: string;
}
function scan(file: string, text: string): Finding[] {
const out: Finding[] = [];
for (const match of text.matchAll(OPTION)) {
out.push({ file, expression: match[1].trim() });
}
return out;
}
function violations(findings: readonly Finding[]): string[] {
return findings
.filter((f) => {
const seconds = literalSeconds(f.expression);
return seconds === null || seconds < FLOOR_SECONDS;
})
.map((f) => `${f.file}: deleteAfterSeconds: ${f.expression}`);
}
function sourceFindings(): Finding[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.filter((p) => p !== HELPER_FILE)
.flatMap((p) => scan(p, readFileSync(join(SRC, p), "utf8")));
}
describe("job retention floor", () => {
it("the matcher sees both a short literal and a named constant", () => {
const planted = scan(
"planted.ts",
[
"await boss.createQueue(Q, { deleteAfterSeconds: 48 * 60 * 60 });",
"await boss.createQueue(R, { deleteAfterSeconds: VOLUME_SECONDS });",
"await boss.createQueue(S, { deleteAfterSeconds: 7 * 24 * 3600 });",
].join("\n"),
);
expect(planted).toHaveLength(3);
expect(violations(planted)).toEqual([
"planted.ts: deleteAfterSeconds: 48 * 60 * 60",
"planted.ts: deleteAfterSeconds: VOLUME_SECONDS",
]);
});
it("every queue retention in the tree keeps failed rows past the reader window", () => {
expect(violations(sourceFindings())).toEqual([]);
});
});
@@ -76,6 +76,10 @@ const WRITERS: Record<string, Disposition> = {
"lib/mcp/writes.ts": { kind: "validator" },
"lib/measurements/create-from-telegram.ts": { kind: "validator" },
"lib/measurements/import-apple-health-export.ts": { kind: "validator" },
// v1.42 — the Health Connect export import. Spot rows go through the bulk
// insert after `validateMeasurementRange`; day totals through the gated
// reconcile seam.
"lib/import/health-connect/import.ts": { kind: "validator" },
"app/api/import/csv/route.ts": {
kind: "upstream",
validatedIn: "lib/import/csv-measurements.ts",
@@ -72,6 +72,8 @@ const WRITE_SITES: Record<string, string> = {
"a primitive; every caller runs the tail over the verdicts' dirty identities",
"lib/measurements/import-apple-health-export.ts":
"the import worker refolds the import's span and re-warms every imported type",
"lib/import/health-connect/import.ts":
"the Health Connect import worker refolds the import's span and re-warms every imported type",
"lib/export/restore-backup.ts":
"a restore replaces the whole record: it refolds the whole window and drops every per-user cache",
// Value-preserving reshapes: the same readings folded into daily or hourly
@@ -98,6 +100,12 @@ const WRITE_SITES: Record<string, string> = {
"encrypts the note column in place; no value, instant or type changes",
"lib/jobs/measurement-tombstone-cleanup.ts":
"hard-deletes rows that were already soft-deleted and read nowhere",
"lib/jobs/compaction-tombstone-purge.ts":
"hard-deletes compaction tombstones, rows already soft-deleted and read nowhere",
"lib/jobs/measurement-fold-repair.ts":
"corrects the value of a folded stats: mean in place, takes the window's live samples into it, and refolds the DAY rollup of a touched daily mean itself; no instant or type changes",
"lib/measurements/fold-constituents.ts":
"soft-deletes the samples a fold or the fold repair has just taken into a recomputed stats: mean, which accounts for them from then on",
};
function sourceFiles(): string[] {
@@ -155,8 +163,12 @@ describe("measurement write tail", () => {
const handRolled = Object.entries(WRITE_SITES)
.filter(([, reason]) => reason.includes("re-warms"))
.map(([file]) => file)
// The two export importers leave both legs to their workers, which
// run them once over the whole import.
.filter(
(file) => file !== "lib/measurements/import-apple-health-export.ts",
(file) =>
file !== "lib/measurements/import-apple-health-export.ts" &&
file !== "lib/import/health-connect/import.ts",
);
expect(handRolled.length).toBeGreaterThan(0);
const missing = handRolled.filter((file) => {
@@ -0,0 +1,158 @@
/**
* v1.41 — what the person told the Coach leaves the machine only when the
* turn may send to its providers.
*
* The memory block (`WHAT YOU KNOW ABOUT THIS PERSON`) carries facts, plans
* and reminders the person wrote; the briefing's plan lines carry plan
* targets. Both are built, not built-then-dropped: the egress or capability
* check runs first and nothing is read on a refusal. Behaviour is pinned in
* `memory/__tests__/context-block.test.ts` and `plan-progress.test.ts`; this
* guard pins the structure those tests rest on:
*
* 1. Inside each builder, the check comes before the first database read.
* 2. The builders are called only from the files that run after the turn's
* own egress check (the Coach turn) or behind the briefing capability (the
* briefing generation), and a Coach-turn caller hands over the chain so
* the wire check runs for exactly those providers.
* 3. No `annotate()` in the memory modules names a text-bearing value.
*
* Every check is also run against a source broken on purpose.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const MEMORY = "lib/ai/coach/memory";
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
}
/** The body of `export async function <name>(`, up to the next top-level `}`. */
function functionBody(source: string, name: string): string {
const start = source.search(
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
);
if (start === -1) return "";
const end = source.indexOf("\n}\n", start);
return source.slice(start, end === -1 ? undefined : end);
}
/** Whether `check` appears before the first database read in `body`. */
function checksBeforeRead(body: string, check: RegExp): boolean {
const checkAt = body.search(check);
const readAt = body.search(
/\bprisma\.|\bload(?:Facts|Plans|Reminders)\(|takePendingProposal\(/,
);
return checkAt !== -1 && readAt !== -1 && checkAt < readAt;
}
describe("memory block egress guard", () => {
const block = functionBody(
read(`${MEMORY}/context-block.ts`),
"buildMemoryContextBlock",
);
const lines = functionBody(
read(`${MEMORY}/plan-progress.ts`),
"buildPlanProgressLines",
);
it("the memory block checks egress before it reads anything", () => {
expect(block.length).toBeGreaterThan(200);
expect(checksBeforeRead(block, /egressAllowed\(/)).toBe(true);
const helper = read(`${MEMORY}/context-block.ts`);
expect(helper).toMatch(/aiEgressRefusal\(\s*"coach"/);
expect(helper).toMatch(/aiCapabilityForRecord\(args\.userId, "coach"\)/);
// Broken on purpose: the check moved below the reads.
const broken = block.replace(/if \(!\(await egressAllowed\(args\)\)\)/, "");
expect(checksBeforeRead(broken, /egressAllowed\(/)).toBe(false);
});
it("the briefing's plan lines check the briefing capability before any read", () => {
expect(lines.length).toBeGreaterThan(200);
expect(
checksBeforeRead(lines, /aiCapabilityForRecord\(userId, "briefing"\)/),
).toBe(true);
const broken = lines.replace(
/aiCapabilityForRecord\(userId, "briefing"\)/,
"",
);
expect(
checksBeforeRead(broken, /aiCapabilityForRecord\(userId, "briefing"\)/),
).toBe(false);
});
/**
* Files that may build the memory block: the Coach turn, after its own
* capability and consent checks (`turn/pipeline.ts` rechecks before it
* runs the model). The contract re-exports it; nothing else calls it.
*/
const BLOCK_CALLERS = new Set([
"lib/ai/coach/turn/model.ts",
"lib/ai/coach/turn/context.ts",
]);
/** Files that may build the briefing's plan lines. */
const LINE_CALLERS = new Set([
"lib/insights/comprehensive-generate.ts",
"lib/insights/briefing-provider.ts",
]);
function callers(name: string): string[] {
const call = new RegExp(`\\b${name}\\(`);
return sourceFiles()
.filter((rel) => !rel.startsWith(`${MEMORY}/`))
.filter((rel) => call.test(read(rel)));
}
it("only the Coach turn builds the memory block, and it names its providers", () => {
const found = callers("buildMemoryContextBlock");
for (const rel of found) {
expect(BLOCK_CALLERS.has(rel), rel).toBe(true);
expect(read(rel), `${rel} hands over the chain`).toMatch(
/buildMemoryContextBlock\(\{[\s\S]{0,400}providerTypes/,
);
}
// The matcher is live: it finds the definition it guards.
const definitions = sourceFiles().filter((rel) =>
/export async function buildMemoryContextBlock\(/.test(read(rel)),
);
expect(definitions).toEqual([`${MEMORY}/context-block.ts`]);
});
it("only the briefing builds the plan lines", () => {
for (const rel of callers("buildPlanProgressLines")) {
expect(LINE_CALLERS.has(rel), rel).toBe(true);
}
});
it("no memory annotation names a text-bearing value", () => {
const TEXT =
/\b(?:fact|text|ifCue|thenAction|target|recalled|note\.fact|userMessage|content)\s*[,:}]/;
const offending: string[] = [];
const files = walkSourceFiles(join(SRC, MEMORY), { floor: 5 }).filter(
(p) => !p.includes("__tests__"),
);
for (const rel of files) {
const source = read(`${MEMORY}/${rel}`);
for (const match of source.matchAll(/annotate\(\{[\s\S]*?\}\);/g)) {
const meta = match[0].split("meta:")[1] ?? "";
if (TEXT.test(meta)) offending.push(`${rel}: ${match[0]}`);
}
}
expect(offending).toEqual([]);
// Broken on purpose: a meta that carries the fact.
const planted = `annotate({ action: { name: "x" }, meta: { fact: call.fact } });`;
expect(TEXT.test(planted.split("meta:")[1])).toBe(true);
});
});
@@ -320,7 +320,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] },
"/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] },
"/api/send": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/umami/{path}": { kind: "internalOps", methods: ["POST"] },
"/api/mcp/oauth/authorize": {
kind: "mcpTransport",
methods: ["GET", "POST"],
@@ -79,6 +79,10 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
"/api/notifications/vapid":
"The instance's public Web Push key. Public by definition.",
"/api/auth/claim/preview":
"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.",
// 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
@@ -175,6 +175,7 @@ const PREFETCHING_ROUTES = [
"/checkups",
"/coach",
"/insights",
"/insights/mood",
"/insights/workouts",
"/medications",
"/mood",
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* v1.42 (#959) — `/claim/<hlp_token>` is the managed-profile handover link.
*
* The twin of `proxy-invite-public.test.ts`: the link is a shape-validated
* edge redirect onto `/auth/claim?token=…`, it carries no session, and the
* hop is uncacheable, unindexable and never leaked as a `Referer`. The two API
* routes behind the page authenticate by the token in the request body, so
* they pass the page gate; on a demo instance they are refused like every
* other mutation off the allowlist.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy, PUBLIC_PATHS } from "../proxy";
const VALID_TOKEN = `hlp_${"a".repeat(64)}`;
function makeRequest(
pathname: string,
init: { cookies?: Record<string, string>; method?: string } = {},
): NextRequest {
const cookieHeader = Object.entries(init.cookies ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(`http://localhost${pathname}`, {
method: init.method ?? "GET",
headers: cookieHeader ? { cookie: cookieHeader } : {},
});
}
describe("proxy.ts /claim public allowlist", () => {
it("lists the landing and the anonymous claim routes as public", () => {
expect(PUBLIC_PATHS).toContain("/claim/");
expect(PUBLIC_PATHS).toContain("/api/auth/claim");
});
it("does not bounce an unauthenticated /claim/<token> to /auth/login", () => {
const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`));
expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/);
});
it("does not bounce a pending-onboarding session off the link", () => {
const res = proxy(
makeRequest(`/claim/${VALID_TOKEN}`, {
cookies: { healthlog_session: "sess-1", hl_onboarding: "pending" },
}),
);
expect(res.headers.get("location") ?? "").not.toMatch(/\/onboarding/);
});
it("lets the anonymous preview and claim reach their handlers", () => {
for (const path of ["/api/auth/claim", "/api/auth/claim/preview"]) {
const res = proxy(makeRequest(path, { method: "POST" }));
expect(res.status, path).not.toBe(401);
expect(res.headers.get("location"), path).toBeNull();
}
});
});
describe("proxy.ts /claim edge redirect", () => {
it("307s a shape-valid token onto the claim page", () => {
const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`));
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe(
`http://localhost/auth/claim?token=${VALID_TOKEN}`,
);
});
it.each([
["an uppercase hex body", `hlp_${"A".repeat(64)}`],
["a short body", `hlp_${"a".repeat(63)}`],
["a long body", `hlp_${"a".repeat(65)}`],
["an invite token", `hlv_${"a".repeat(64)}`],
["markup", "%3Cscript%3E"],
])("drops %s rather than echoing it into the target", (_label, segment) => {
const res = proxy(makeRequest(`/claim/${segment}`));
expect(res.headers.get("location")).toBe("http://localhost/auth/claim");
});
it("hardens the token-in-path hop against cache, index and referrer leaks", () => {
const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`));
expect(res.headers.get("Cache-Control")).toBe(
"no-store, no-cache, must-revalidate",
);
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(res.headers.get("Referrer-Policy")).toBe("no-referrer");
});
});
describe("proxy.ts DEMO_MODE refuses the handover", () => {
const original = process.env.DEMO_MODE;
afterEach(() => {
if (original === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = original;
});
it.each([
["POST", "/api/auth/claim"],
["POST", "/api/auth/claim/preview"],
["POST", "/api/managed-profiles/p1/handover"],
["DELETE", "/api/managed-profiles/p1/handover"],
["POST", "/api/account/handover-decision"],
])("403s %s %s on a demo instance", async (method, path) => {
process.env.DEMO_MODE = "true";
const res = proxy(
makeRequest(path, {
method,
cookies: { healthlog_session: "sess-1" },
}),
);
expect(res.status).toBe(403);
});
it("leaves the same claim alone off demo mode", () => {
delete process.env.DEMO_MODE;
const res = proxy(makeRequest("/api/auth/claim", { method: "POST" }));
expect(res.status).not.toBe(403);
});
});
@@ -154,11 +154,30 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
).toBe(403);
});
it("permits the record search, a read that travels as a POST", () => {
// The query rides the body so it stays out of URLs and access logs; the
// route writes nothing, so the demo searches like any other instance.
expect(proxy(makeRequest("/api/search", "POST")).status).not.toBe(403);
expect(proxy(makeRequest("/api/search", "DELETE")).status).toBe(403);
});
it("still blocks a health-data mutation (POST /api/measurements)", () => {
const res = proxy(makeRequest("/api/measurements", "POST"));
expect(res.status).toBe(403);
});
it("refuses the Coach chat with a code a client can name", async () => {
// The chat POST is not on the allowlist. The refusal carries a stable
// code, so the Coach says "not in the demo" instead of reading the 403
// as a provider outage.
const res = proxy(makeRequest("/api/insights/chat", "POST"));
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { demo?: boolean; errorCode?: string };
};
expect(body.meta).toEqual({ demo: true, errorCode: "demo.readOnly" });
});
it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => {
// The allowlist pins method per path; admitting the widgets PUT
// must not open DELETE (which wipes the user's dashboard layout).
@@ -0,0 +1,253 @@
/**
* Structural guard: every reader that averages pulse goes through the
* day-mean helper.
*
* A watch samples pulse every few minutes at rest and every few seconds in a
* workout, so a plain mean over pulse readings lets one workout hour outweigh
* the rest of the day. A day of pulse is the mean of its hours' means and a
* window the mean of its days (`src/lib/measurements/day-statistic.ts`), and
* `src/lib/measurements/day-mean.ts` is the one place that computes it. The
* statistic first shipped in the chart and the rollup tier while some twenty
* other readers kept the plain mean, so the dashboard's year delta compared
* one statistic against another.
*
* Two matchers, both frozen both ways (a new unlisted match fails, a listed
* file that no longer matches fails):
*
* 1. Database means over measurement values: `AVG(value)`, the per-day
* folds `SUM(total) / SUM(cnt)` and `SUM(count * mean)`, and Prisma's
* `_avg: { value }`. A file that holds one imports the shared statistic
* (it averages pulse among other types and the helper's CASE keeps the
* others on the plain mean).
*
* 2. In-memory means in a file that names pulse: a left fold divided by a
* length, `avgInWindow(`, `summarize(`, `bucketWeekly(`,
* `buildDailyValueRows(` or `applyPayloadBudget(`. A file that holds one
* imports the shared statistic, hands the type to a fold that does, or
* is listed with the reason it never averages pulse readings.
*
* Comments are stripped before matching, the matchers are whitespace-tolerant,
* and the floors fail the guard if either matcher stops finding anything. The
* planted cases run each matcher over an offender; dropping the type from the
* mood correlation's fold, or a new file with a plain `AVG(value)` over pulse,
* fails the two list checks. It is a tripwire, not a proof: a mean written
* some other way slips it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The shared statistic: the helper, or the type set it is keyed on. */
const HELPER_IMPORT =
/from\s+["'](?:@\/lib\/measurements\/|\.\/)day-(?:mean|statistic)["']/;
/** Comments go first: a mean named in prose is not a mean computed. */
function stripComments(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "")
.replace(/^\s*--.*$/gm, "");
}
/**
* Database means over a measurement value: SQL `AVG(value)`, the per-day folds
* `SUM(total) / SUM(cnt)` and `SUM(count * mean)`, and Prisma's
* `_avg: { value: ... }`.
*/
const SQL_MEAN =
/_avg\s*:\s*\{\s*value\s*:|\bAVG\s*\(\s*(?:[a-z_]+\s*\.\s*)?"?value"?\s*\)|\bSUM\s*\(\s*(?:[a-z_]+\s*\.\s*)?"?total"?\s*\)\s*\/\s*SUM\s*\(\s*(?:[a-z_]+\s*\.\s*)?"?cnt"?\s*\)|\bSUM\s*\(\s*"?count"?\s*\*\s*"?mean"?\s*\)/gi;
/** In-memory means and the folds that make them, counted in files that name pulse. */
const TS_MEAN =
/\.reduce\(\s*\(\s*\w+\s*,\s*\w+\s*\)\s*=>\s*\w+\s*\+\s*[\w.]+\s*,\s*0\s*\)\s*\/\s*[\w.]+\.length|\bavgInWindow\s*\(|\bsummarize\s*\(|\bbucketWeekly\s*\(|\bbuildDailyValueRows\s*\(|\bapplyPayloadBudget\s*\(/g;
const NAMES_PULSE = /\bPULSE\b/;
type Entry =
/** Averages pulse and imports the shared statistic. */
| { helper: true }
/** Averages pulse through a fold that imports it, and hands that fold the type. */
| { via: string; passes: RegExp }
/** Never averages pulse readings; the reason is the claim. */
| { exempt: string };
const HELPER: Entry = { helper: true };
/** Files with a database mean over values. */
const SQL_READERS: Record<string, Entry> = {
"app/api/measurements/series/route.ts": HELPER,
"lib/ai/coach/tools/availability.ts": HELPER,
"lib/analytics/summaries-slice.ts": HELPER,
"lib/doctor-report/dense-buckets.ts": HELPER,
"lib/insights/comprehensive-aggregator.ts": HELPER,
"lib/insights/comprehensive-generate.ts": HELPER,
"lib/measurements/daily-series-read.ts": HELPER,
"lib/rollups/measurement-rollups.ts": HELPER,
"lib/rollups/tiered-context.ts": HELPER,
};
/** Files that name pulse and hold an in-memory mean or a mean-making fold. */
const TS_READERS: Record<string, Entry> = {
"app/api/measurements/series/route.ts": HELPER,
"lib/doctor-report/collect.ts": HELPER,
"lib/doctor-report-pdf/clinical-summary.ts": HELPER,
"lib/insights/comprehensive-generate.ts": HELPER,
"lib/insights/features.ts": HELPER,
"lib/insights/mood-status.ts": {
via: "lib/insights/bucket-series.ts",
passes: /\{\s*now\s*,\s*tz\s*,\s*type\s*\}/,
},
"lib/insights/pulse-status.ts": {
via: "lib/insights/bucket-series.ts",
passes:
/applyPayloadBudget\(\s*pulsePoints\s*,\s*\{[^}]*type\s*:\s*"PULSE"/,
},
"lib/insights/signals-of-day.ts": {
via: "lib/insights/features.ts",
passes: /byType\(\s*"PULSE"\s*\)[\s\S]{0,80}type\s*:\s*"PULSE"/,
},
"lib/ai/coach/snapshot-blocks/core-metrics-block.ts": {
via: "lib/ai/coach/snapshot-series.ts",
passes: /buildDailyValueRows\([^)]*"PULSE"/,
},
"components/day/day-values-model.ts": {
exempt:
"means several readings of a type the day route sends one by one (weight, blood pressure, glucose); pulse arrives as one server-side day value (`dayValueShape` in lib/day/values.ts)",
},
"components/charts/health-chart.tsx": {
exempt:
"a rolling average over the points the chart draws: display smoothing, not a stated figure",
},
"lib/analytics/correlations-fast-path.ts": {
exempt: "mood mean; pulse enters as the resting series, never a day mean",
},
"lib/dashboard/snapshot.ts": {
exempt: "summarises water-intake days and glucose contexts",
},
"lib/gamification/expansion-metrics.ts": {
exempt: "means of mood day means; pulse is only counted",
},
"lib/illness/correlation.ts": { exempt: "mean of episode durations" },
"lib/insights/mood-aggregates.ts": { exempt: "the mood series only" },
"lib/insights/narrative/period-narrative.ts": {
exempt: "means of day-keyed series, already one value per day",
},
"lib/mcp/rich-reads.ts": {
exempt:
"changepoints over rollup bucket means, which weigh pulse days once (readCanonicalRollupBuckets)",
},
"lib/medications/efficacy/build-efficacy.ts": {
exempt: "means of the day-mean series (readDayMeanSeries)",
},
"lib/targets/build-response.ts": {
exempt:
"pulse is excluded from the 30-day averages; the resting proxy stands in",
},
"lib/targets/vitals-builder.ts": {
exempt:
"trend halves over the other vitals and the resting proxy's day points",
},
"app/api/insights/cards/route.ts": {
exempt: "pulse is summarised from day means (readDayMeanSeries)",
},
"app/api/insights/comprehensive/route.ts": {
exempt: "summarises the mood series only",
},
};
function scan(): { sql: Map<string, number>; ts: Map<string, number> } {
const files = walkSourceFiles(SRC, { floor: 1500 }).filter(
(f) =>
!f.startsWith("generated/") &&
!f.includes("__tests__/") &&
!/\.test\.tsx?$/.test(f) &&
f !== "lib/measurements/day-mean.ts",
);
const sql = new Map<string, number>();
const ts = new Map<string, number>();
for (const f of files) {
const text = stripComments(readFileSync(join(SRC, f), "utf8"));
const s = text.match(SQL_MEAN)?.length ?? 0;
if (s > 0) sql.set(f, s);
if (NAMES_PULSE.test(text)) {
const t = text.match(TS_MEAN)?.length ?? 0;
if (t > 0) ts.set(f, t);
}
}
return { sql, ts };
}
const { sql, ts } = scan();
const source = (f: string) => readFileSync(join(SRC, f), "utf8");
function check(found: Map<string, number>, listed: Record<string, Entry>) {
const unlisted = [...found.keys()].filter((f) => !(f in listed)).sort();
const stale = Object.keys(listed)
.filter((f) => !found.has(f))
.sort();
const broken: string[] = [];
for (const [f, entry] of Object.entries(listed)) {
if (!found.has(f)) continue;
if ("helper" in entry && !HELPER_IMPORT.test(source(f))) {
broken.push(`${f}: does not import the day-mean helper`);
}
if ("via" in entry) {
if (!HELPER_IMPORT.test(source(entry.via))) {
broken.push(`${f}: ${entry.via} does not import the day-mean helper`);
}
if (!entry.passes.test(source(f))) {
broken.push(`${f}: no longer hands the type to ${entry.via}`);
}
}
}
return { unlisted, stale, broken };
}
describe("pulse day-mean reader guard", () => {
it("finds the SQL means it is meant to find", () => {
expect(sql.size).toBeGreaterThanOrEqual(9);
expect([...sql.values()].reduce((a, b) => a + b, 0)).toBeGreaterThanOrEqual(
15,
);
});
it("finds the in-memory pulse means it is meant to find", () => {
expect(ts.size).toBeGreaterThanOrEqual(20);
});
it("every SQL mean over values is listed, and every listed pulse reader uses the helper", () => {
expect(check(sql, SQL_READERS)).toEqual({
unlisted: [],
stale: [],
broken: [],
});
});
it("every in-memory pulse mean is listed, and every listed reader uses the helper", () => {
expect(check(ts, TS_READERS)).toEqual({
unlisted: [],
stale: [],
broken: [],
});
});
it("catches a planted plain AVG and a planted fold", () => {
expect(
`SELECT AVG( m."value" ) FROM measurements m`.match(SQL_MEAN),
).toHaveLength(1);
expect(`(SUM(c.total) / SUM(c.cnt))`.match(SQL_MEAN)).toHaveLength(1);
expect(`SUM("count" * "mean")`.match(SQL_MEAN)).toHaveLength(1);
expect(`aggregate({ _avg: { value: true } })`.match(SQL_MEAN)).toHaveLength(
1,
);
expect(stripComments("// AVG(value) in prose").match(SQL_MEAN)).toBeNull();
const planted = `const pulse = byType("PULSE");
const avg = pulse.reduce((s, r) => s + r.value, 0) / pulse.length;`;
expect(NAMES_PULSE.test(planted)).toBe(true);
expect(planted.match(TS_MEAN)).toHaveLength(1);
});
});
@@ -0,0 +1,162 @@
/**
* Structural guards for the installed app on a phone — the parts no browser
* in CI can show, because neither Chromium nor WebKit under Playwright
* reports a safe-area inset or opens an on-screen keyboard.
*
* 1. The authenticated shell takes the status-bar and side insets once,
* on its outer wrapper, and no 4rem header band pads itself inside its
* own height (which on a phone with a 59 px inset left the top bar a
* 5 px content box and pushed the logo across its bottom border).
* 2. The stylesheet carries the rules the shell and the keyboard bridge
* depend on.
* 3. Every form-field primitive keeps 16 px type on a touch screen: iOS
* Safari zooms the page into a focused field below 16 px, and a width
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
* and to every iPad. The small size is allowed only behind
* `pointer-fine:`.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = resolve(__dirname, "../..");
const read = (path: string) => readFileSync(resolve(ROOT, path), "utf8");
/** Drop `//` and block comments so prose about a class cannot satisfy a check. */
function code(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
}
describe("the shell owns the safe area", () => {
it("the authenticated shell wrapper takes the insets", () => {
const shell = code(read("src/components/layout/auth-shell.tsx"));
expect(shell).toMatch(/className="shell-safe-area flex h-dvh flex-col"/);
});
it.each([
"src/components/layout/top-bar.tsx",
"src/components/insights/coach-panel/conversations-panel.tsx",
"src/components/day/day-layer.tsx",
])("%s pads no header band with the status-bar inset", (path) => {
const source = code(read(path));
expect(source).not.toMatch(/paddingTop:\s*"env\(safe-area-inset-top/);
expect(source).not.toMatch(/\bpt-\[env\(safe-area-inset-top/);
});
it("globals.css defines the shell band, the keyboard rules and pointer-fine", () => {
const css = read("src/app/globals.css");
expect(css).toMatch(
/\.shell-safe-area\s*\{[^}]*padding-top:\s*env\(safe-area-inset-top/,
);
expect(css).toMatch(
/\[data-sheet-side="bottom"\]\s*\{\s*bottom:\s*var\(--keyboard-inset/,
);
expect(css).toMatch(
/html\[data-keyboard="open"\] \[data-keyboard-hide\]\s*\{\s*display:\s*none/,
);
expect(css).toMatch(/@custom-variant pointer-fine/);
});
it("bottom toasts clear the bar under the same query the bar follows", () => {
// Sonner's own mobile offset stops at 600 px; the bar shows up to `md`
// and on a phone held sideways, so the lift keys on `shell-mobile`.
const css = code(read("src/app/globals.css"));
expect(css).toMatch(
/html \[data-sonner-toaster\]\[data-y-position="bottom"\]\s*\{\s*@variant shell-mobile\s*\{\s*bottom:\s*calc\(4rem \+ env\(safe-area-inset-bottom/,
);
});
it("the bottom bar and the Coach button step aside for the keyboard", () => {
expect(read("src/components/layout/bottom-nav.tsx")).toContain(
'data-keyboard-hide=""',
);
expect(read("src/components/insights/layout-coach-fab.tsx")).toContain(
'data-keyboard-hide=""',
);
});
it("a bottom sheet on the keyboard drops its home-indicator padding", () => {
// Standing on the keyboard, the safe-area inset left a ~34 px gap
// between the sheet's last row and the keys.
expect(code(read("src/app/globals.css"))).toMatch(
/@custom-variant keyboard-open \(html\[data-keyboard="open"\] &\);/,
);
const sheet = code(read("src/components/ui/responsive-sheet.tsx"));
const padded = (sheet.match(
/"[^"]*pb-\[calc\(env\(safe-area-inset-bottom[^"]*"/g,
) ?? []) as string[];
expect(padded.length).toBe(2);
for (const cls of padded) expect(cls).toMatch(/\bkeyboard-open:pb-4\b/);
});
it("those rules sit under the utilities, and the bottom sheet sets no bottom utility", () => {
// Unlayered, the rules outranked every utility on the same element: a
// `bg-*` on the shell or a `bottom-*` on a sheet would have been ignored
// without a trace. In the components layer a utility wins, so the bottom
// sheet must not carry one that would pin it under the keyboard.
const css = code(read("src/app/globals.css"));
const start = css.indexOf("@layer components {");
expect(start).toBeGreaterThan(-1);
let depth = 0;
let end = start;
for (let i = css.indexOf("{", start); i < css.length; i += 1) {
if (css[i] === "{") depth += 1;
else if (css[i] === "}" && --depth === 0) {
end = i;
break;
}
}
const layer = css.slice(start, end);
expect(layer).toMatch(/\.shell-safe-area\s*\{/);
expect(layer).toMatch(/\[data-sheet-side="bottom"\]\s*\{\s*bottom:/);
expect(layer).toMatch(/\[data-keyboard-hide\]\s*\{\s*display:\s*none/);
expect(layer).toMatch(/\[data-sheet-side="left"\]\s*\{\s*border-left:/);
// Sonner's own rules are unlayered; the toast lift must stay so.
expect(layer).not.toMatch(/data-sonner-toaster/);
const sheet = code(read("src/components/ui/sheet.tsx"));
const bottomBranch = sheet.match(/side === "bottom" &&\s*"([^"]*)"/)?.[1];
expect(bottomBranch).toBeDefined();
expect(bottomBranch).not.toMatch(/(?<![\w-])bottom-/);
});
it("the sheet primitive names its side for those rules", () => {
expect(read("src/components/ui/sheet.tsx")).toMatch(
/data-sheet-side=\{side\}/,
);
});
});
describe("form fields keep 16 px on a touch screen", () => {
const FIELDS = [
"src/components/ui/input.tsx",
"src/components/ui/textarea.tsx",
"src/components/ui/native-select.tsx",
"src/components/ui/date-field.tsx",
"src/components/ui/time-field.tsx",
];
it.each(FIELDS)(
"%s sets text-base and shrinks only under a fine pointer",
(path) => {
// The field's own box is the class string that draws its border; the
// rest of the file (a picker's buttons, a caption) is not typed into.
const boxes = (code(read(path)).match(
/"[^"]*\bborder-input(?![\w-])[^"]*"/g,
) ?? []) as string[];
expect(boxes.length, `${path} has no field class`).toBeGreaterThan(0);
for (const box of boxes) {
expect(box).toMatch(/\btext-base\b/);
// Any sub-16 px size must sit behind `pointer-fine:`; `file:` styles
// the upload button, not typed text.
const bare = box.match(
/(?<![\w:-])(?:(?:sm|md|lg|xl):)?text-(?:xs|sm)(?![\w-])/g,
);
expect(bare ?? []).toEqual([]);
}
},
);
});
@@ -0,0 +1,34 @@
/**
* `readOverPost` lets a delegable route answer a POST at the grant's read
* level. That widens what a read-only grant reaches, so the set of routes
* declaring it is frozen here: adding one is a reviewed diff, not an
* accident. Today it is the record search alone, whose query is health data
* that must stay out of URLs.
*
* The matcher tolerates whitespace and line breaks, and the guard asserts it
* matched something, so a rewrite that hides the declaration from the regex
* fails instead of passing on an empty set.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { globSync } from "node:fs";
import { join, relative, sep } from "node:path";
const ROOT = process.cwd();
const FROZEN = ["src/app/api/search/route.ts"];
describe("readOverPost declarations", () => {
it("are exactly the frozen set", () => {
const declaring = globSync("src/**/*.ts", { cwd: ROOT })
.filter((file) => !file.includes(`${sep}__tests__${sep}`))
.filter((file) => file !== join("src", "lib", "api-handler.ts"))
.filter((file) =>
/readOverPost\s*:\s*true/.test(readFileSync(join(ROOT, file), "utf8")),
)
.map((file) => relative(ROOT, join(ROOT, file)).split(sep).join("/"))
.sort();
expect(declaring.length).toBeGreaterThan(0);
expect(declaring).toEqual(FROZEN);
});
});
@@ -58,6 +58,7 @@ const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [
],
["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"],
["src/app/api/vaccinations/route.ts", "/api/vaccinations"],
["src/app/api/vaccinations/custom/route.ts", "/api/vaccinations/custom"],
["src/app/api/allergies/route.ts", "/api/allergies"],
["src/app/api/biomarkers/route.ts", "/api/biomarkers"],
["src/app/api/encounters/route.ts", "/api/encounters"],
@@ -144,8 +144,8 @@ const RULES: ResolvedFieldRule[] = [
allow: {
"src/components/settings/ai/disable-coach-card.tsx":
"the Activate-Coach switch: it edits the stored opt-out, so its checked state is that value",
"src/components/settings/modules-section.tsx":
"the module toggle row for the Coach: its checked state is the stored opt-out it writes",
"src/hooks/use-module-toggle.ts":
"the module switch for the Coach (Settings hub and command palette): its checked state is the stored opt-out it writes",
"src/components/settings/managed-record-settings-section.tsx":
"the managed record's own settings form: the field's default is the record's stored opt-out",
"src/hooks/use-auth.ts":
@@ -169,6 +169,8 @@ const RULES: ResolvedFieldRule[] = [
allow: {
"src/components/settings/modules-section.tsx":
"the Modules hub: it shows the operator-off state of each toggle row, which is what `moduleAvailability` is published for",
"src/components/command-palette/use-palette-index.ts":
"the command palette's module switches: like the Modules hub it leaves out a switch the operator turned off, which is what `moduleAvailability` is published for",
"src/hooks/use-auth.ts":
"the `/me` payload normaliser: types and coerces the field, decides nothing",
},
@@ -0,0 +1,48 @@
/**
* Links point at a route that renders, not at a retired one that only
* redirects: every hop through a redirect is a round trip on a phone, and
* the redirect stubs exist for old bookmarks, not for the app's own links.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..", "..");
/** Retired route → where it redirects. */
const RETIRED: Record<string, string> = {
"/medications/new": "/medications?new=1",
};
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
if (name === "__tests__" || name === "generated") return [];
const abs = join(dir, name);
if (statSync(abs).isDirectory()) return walk(abs);
return /\.tsx?$/.test(name) ? [abs] : [];
});
}
/** Every regex metacharacter escaped, the backslash included. */
function escapeRegExp(text: string): string {
return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
}
describe("no link targets a retired route", () => {
const files = walk(join(ROOT, "src"));
it("scans the source tree", () => {
expect(files.length).toBeGreaterThan(100);
});
it.each(Object.entries(RETIRED))("%s is linked as %s", (route) => {
const pattern = new RegExp(
`(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${escapeRegExp(route)}["'\`?#]`,
);
const offenders = files
.filter((file) => pattern.test(readFileSync(file, "utf8")))
.map((file) => relative(ROOT, file));
expect(offenders).toEqual([]);
});
});
+21
View File
@@ -217,6 +217,10 @@ function bootServiceWorker(): SwHarness {
URL,
Promise,
console,
// Read through the context so a test can install fake timers before
// dispatching (the slow-navigation fallback arms one per navigation).
setTimeout: (...args: Parameters<typeof setTimeout>) => setTimeout(...args),
clearTimeout: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
};
vm.createContext(context);
vm.runInContext(SW_SOURCE, context);
@@ -617,6 +621,23 @@ describe("sw.js — networkFirst offline fallback", () => {
});
});
describe("sw.js — offline page", () => {
it("offers a retry control, reloads by itself when the connection returns, and follows the device scheme", async () => {
const harness = bootServiceWorker();
const res = await dispatchNavigationFetch(harness, "/");
expect(res.status).toBe(503);
const html = await res.text();
// A real control, not a decorative icon: a link back to the same URL.
expect(html).toMatch(/<a href="" aria-label="[^"]+"/);
expect(html).toContain('addEventListener("online"');
expect(html).toContain("prefers-color-scheme:light");
expect(html).toContain("viewport-fit=cover");
expect(html).toContain("env(safe-area-inset-bottom)");
// Still language-neutral: no sentence in any one locale.
expect(html).not.toMatch(/Keine Verbindung|You are offline/);
});
});
describe("sw.js — networkFirst privacy gate", () => {
it("does not cache a navigation response that carries Cache-Control: no-store", async () => {
const harness = bootServiceWorker();
+42 -2
View File
@@ -85,6 +85,17 @@ describe("S1 — the direct session-resolution set is frozen", () => {
// here twice over: it would throw 401 at the anonymous caller the route
// exists for, and this route wants the ABSENCE of a session.
"app/api/auth/register/route.ts": "refuses to register over a live session",
// v1.42 (#959) — the anonymous managed-profile claim and its preview. Same
// argument as registration: claiming signs the new owner in, so a Guardian
// opening their own handover link must be refused rather than signed out
// of their account and into the person's. The probe only asks whether a
// session EXISTS; the credential these routes act on is the one-time
// `hlp_` token in the body, and the probe is resolved in the route file
// itself (not inside `claimGate`) so both stay visible on this list.
"app/api/auth/claim/route.ts":
"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",
// 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":
@@ -127,6 +138,8 @@ describe("S1 — the direct session-resolution set is frozen", () => {
"RSC prefetch of the session user's coach state, skipped under a switch",
"app/insights/page.tsx":
"RSC prefetch of the session user's insights, skipped under a switch",
"app/insights/mood/page.tsx":
"RSC prefetch of the session user's mood calendar and line, skipped under a switch",
"app/insights/workouts/page.tsx":
"RSC prefetch of the session user's workouts, skipped under a switch",
"app/medications/page.tsx":
@@ -223,6 +236,9 @@ describe("S2 — the URL-token authentication set is frozen", () => {
"app/c/[token]/d/[id]/route.ts": "serves a shared document's bytes",
"app/c/[token]/fhir/route.ts": "serves the shared record as a FHIR bundle",
"app/c/[token]/report.pdf/route.ts": "serves the shared record as a PDF",
// The segment's not-found page: the copy a dead link shows. It reads no
// token and resolves nothing; the page above decides, and answers 404.
"app/c/[token]/not-found.tsx": "renders the dead-link copy; reads no token",
// The passphrase gate for the same token. Anonymous by design; mints the
// token-scoped unlock cookie the view above checks.
"app/api/c/[token]/unlock/route.ts": "verifies the share-link passphrase",
@@ -243,7 +259,25 @@ describe("S2 — the URL-token authentication set is frozen", () => {
// Listed because it is a URL-token surface, with the reason being that
// it authenticates nothing.
"app/invite/[token]/page.tsx":
"validates the invite token's shape and redirects; no lookup",
"validates the invite token's shape and redirects, or says the link is invalid; no lookup",
// v1.42 (#959) — the managed-profile handover link. The landing is the
// invite landing's twin: a shape check on the `hlp_` segment and a
// redirect, no lookup (the proxy normally answers it at the edge).
"app/claim/[token]/page.tsx":
"validates the handover token's shape and redirects; no lookup",
// The claim page reads the token off `?token=` and sends it in the BODY of
// the anonymous preview and claim requests, which are the only places a
// handover token is resolved. The page itself authenticates nothing and
// renders nothing about the profile before the preview answers.
"app/auth/claim/page.tsx":
"carries the handover token from the query string into the claim requests' body",
// v1.42.2 — notification quick answers. A notification app (ntfy's
// `http` action) calls it with no session, so the signed, user- and
// action-bound, short-lived token in the path is the credential. Rate
// limited before verification, single-use through the mood entry's unique
// key, and scrubbed from logs by `PATH_SECRET_PATHS`.
"app/api/notifications/action/[token]/route.ts":
"signed single-use quick-answer token from a notification action",
};
/**
@@ -277,12 +311,15 @@ describe("S2 — the URL-token authentication set is frozen", () => {
expect(bySegment.length).toBeGreaterThan(0);
expect(bySegment).toEqual([
"app/api/c/[token]/unlock/route.ts",
"app/api/notifications/action/[token]/route.ts",
"app/api/whoop/webhook/[token]/route.ts",
"app/api/withings/webhook/[token]/route.ts",
"app/c/[token]/d/[id]/route.ts",
"app/c/[token]/fhir/route.ts",
"app/c/[token]/not-found.tsx",
"app/c/[token]/page.tsx",
"app/c/[token]/report.pdf/route.ts",
"app/claim/[token]/page.tsx",
"app/invite/[token]/page.tsx",
]);
});
@@ -304,7 +341,10 @@ describe("S2 — the URL-token authentication set is frozen", () => {
const byQuery = appFilesMatching(QUERY_CREDENTIAL);
expect(byQuery.length).toBeGreaterThan(0);
expect(byQuery).toEqual(["app/api/withings/webhook/route.ts"]);
expect(byQuery).toEqual([
"app/api/withings/webhook/route.ts",
"app/auth/claim/page.tsx",
]);
});
it("the three legs together equal the frozen allowlist", () => {
+89 -2
View File
@@ -971,6 +971,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "Which of the record's doses a scanned page dated around a given day belongs to. A read over the same rows the immunization list serves, reduced to a verdict — the caller learns nothing it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
},
"app/api/vaccinations/custom/route.ts": {
domain: "profile",
why: "The record's own vaccine definitions: a name, the antigens from the catalogue's closed list, a series length and a booster interval. Part of the immunization history the dose list already shows a `profile` grant, and it names no credential, no integration and no notification channel. Every row is read and written under the resolved user id. The create arm is a delegable write.",
},
"app/api/vaccinations/custom/[id]/route.ts": {
domain: "profile",
why: "One of the record's own vaccine definitions, fetch-then-guard against the resolved user. Reached only through its MANAGE arms; a definition another record holds is a 404 like any foreign id.",
},
"app/api/practitioners/route.ts": {
domain: "profile",
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
@@ -1007,6 +1015,40 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "illness",
why: "One symptom occurrence of the record, fetch-then-guard against the resolved user. Reached only through this module's MANAGE arm.",
},
// v1.42 (#613) — the day, its index and the visit preparation are admitted
// on the readings and narrowed section by section after that: every other
// section is read only when the grant also covers that section's own
// domain (`actingDomainVisibility`), and a section it does not cover is
// named `not_shared` and never read. The section-to-domain map
// (`src/lib/day/sections.ts`) is the one the routes serving those rows
// declare here, so the day never shows more of a section than its own list
// would. The timeline and its readiness lay every section side by side and
// stay whole-record reads; their lanes are narrowed the same way all the
// same.
"app/api/day/[date]/route.ts": {
domain: "measurements",
why: "One local day of the record: the readings in its window, and the other sections only where the grant covers each one's own domain, the rest named not_shared and never read. Read-only, every row scoped to the resolved user, decrypted only for the session that resolved it.",
},
"app/api/day/index/route.ts": {
domain: "measurements",
why: "Which days of a bounded window hold anything, by section key, narrowed per section like the day. Dates and section names only, no values and no free text; every read scoped to the resolved user.",
},
"app/api/day/notable/route.ts": {
domain: "measurements",
why: "The preparation of the next visit: notable days of the readings and the context changes of the sections the grant covers, as keys and the record's own names. Read-only, scoped to the resolved user, narrowed per section like the day.",
},
"app/api/timeline/route.ts": {
domain: "record",
why: "The record over the years: spans and points from every section plus value series. Read-only, scoped to the resolved user, module-gated on the record's own `timeline` switch.",
},
"app/api/timeline/readiness/route.ts": {
domain: "record",
why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.",
},
"app/api/search/route.ts": {
domain: "record",
why: "One search over the record: hits from every section with an in-app path each. Read-only, scoped to the resolved user, every source narrowed to the record's module switches and to the sections the grant covers, the query never logged.",
},
"app/api/workouts/route.ts": {
domain: "measurements",
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
@@ -1144,6 +1186,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "documents",
why: "The record's vault state: quota, the filter bar's condition chips, and index coverage. Without it the admitted list loses its filters. The `assistAvailable` boolean is the same integration-adjacent availability flag `nutrients` already returns — no credential, endpoint or token — and every AI action it would gate stays refused. No write arm exists.",
},
"app/api/documents/inbound/layout/route.ts": {
domain: "documents",
why: "The record's vault presentation, read only: two closed enums (card or list view, stacked or flowing months) and nothing else. The admitted list renders through it, so a refused read would leave a delegate's vault waiting on a shape it can never learn. The PUT stays bare — how the owner's vault is laid out is the owner's choice, the same split `/api/medications/layout` keeps.",
},
/* ------------------------------------------------------------------ */
/* v1.37.0 — the fifty-one modules that became delegable at MANAGE. */
@@ -1372,6 +1418,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "record",
why: "Rhythm events over the record's own recordings. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/insights/score-history/route.ts": {
domain: "record",
why: "A score's daily course: the stored health-score days, the nightly readiness rows, the per-night sleep score. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/insights/targets/route.ts": {
domain: "record",
why: "The record's resolved targets. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
@@ -1457,6 +1507,8 @@ const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
"Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.",
"app/api/vaccinations/[id]/booster/route.ts":
"Confirming the booster reminder a dose suggests. The reminder is minted under the RECORD and keyed on the antigen the server reads from the dose's catalogue entry, never from the body, so a delegate cannot point it at an antigen the dose does not contain; a second confirmation re-anchors the one reminder rather than minting another.",
"app/api/vaccinations/custom/route.ts":
"Adding a vaccine definition to the record. It lands under the RECORD, which is correct: it is the owner's Impfpass a helper is transcribing, and the doses logged against it are the owner's. The rate bucket keys on the ACTOR, and a name the record already holds is the ordinary 409 the owner would hit themselves.",
"app/api/medications/[id]/side-effects/route.ts":
"Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.",
"app/api/medications/route.ts":
@@ -1603,6 +1655,11 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "Reopening a deleted dose. The document links survived the tombstone, so it comes back with its page still filed against it.",
},
"app/api/vaccinations/custom/[id]/route.ts": {
domain: "profile",
conditions: ["C4"],
why: "Correcting and removing one vaccine definition. The removal soft-deletes it and lets go of the doses that named it, each keeping a name, so no dose leaves the record; the audit row names the definition and how many doses it let go. C4 on the edit: the antigens, the series length and the booster interval are filed before and after, and a rename is named, never quoted. Neither verb re-runs the booster satisfy matcher, so a delegate cannot move a booster's due date by correcting a definition.",
},
"app/api/illness/episodes/[id]/resolve/route.ts": {
domain: "illness",
conditions: [],
@@ -1883,6 +1940,11 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "Rhythm events over the record's own recordings.",
},
"app/api/insights/score-history/route.ts": {
domain: "record",
conditions: [],
why: "A score's daily course, read from the same rows the score pages and the day view read. Deterministic, no provider, nothing enqueued.",
},
"app/api/insights/targets/route.ts": {
domain: "record",
conditions: [],
@@ -2010,8 +2072,33 @@ const ACTOR_ROUTES: Record<string, string> = {
* list, the occurrence create on the write literal, and the definition create,
* the definition edit/delete and the occurrence edit/delete on the manage
* literal. 232 -> 242 with both.
*
* v1.42 -- the person's own vaccine definitions (#1005) add four: both
* modules on the record list, the create on the write literal and the
* edit/delete on the manage literal. 242 -> 246.
*
* v1.42 -- the day view and the timeline add five reads on the record list:
* the day, the day index, the timeline, its readiness inventory and the
* life-event list in `profile`. 246 -> 251.
*
* v1.42 -- the visit preparation joins the record list beside the day, and
* the life-event writes join it with their audit rows: the edit/delete module
* on the record list, the create and the edit/delete on the manage literal.
* 251 -> 255.
*
* v1.42 -- the document vault's presentation read joins the record list
* beside the rest of the vault; its PUT stays bare. 255 -> 256.
*
* v1.42 -- life events leave every list: they are owner-only in this
* release (no share level was worded for them), so both modules take a bare
* `requireAuth()` and drop off the record list and the manage literal.
* 256 -> 252.
*
* v1.42 — the score-history read joins the record list and the manage
* literal (a score's daily course, MANAGE like the derived score itself).
* 252 -> 254.
*/
const FROZEN_ENTRY_COUNT = 242;
const FROZEN_ENTRY_COUNT = 255;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2772,7 +2859,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(84);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
+97
View File
@@ -0,0 +1,97 @@
/**
* Raw SQL never reads the clock through the session time zone.
*
* Stored times are UTC in `timestamp` columns without a zone. Postgres
* compares such a column with `NOW()` / `CURRENT_TIMESTAMP` /
* `clock_timestamp()` by converting through the session time zone, and
* assigns one of them to such a column the same way, so on a database whose
* default is not UTC a fresh passkey challenge read as already expired. Every
* connection is pinned to UTC (`@/lib/db-session`), and on top of that every
* raw-SQL clock read writes `NOW() AT TIME ZONE 'UTC'`, which yields the naive
* UTC timestamp whatever the session says.
*
* The few bare reads left are listed below with the reason they are safe:
* each feeds a `timestamptz` column or returns the instant to JavaScript.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..", "..");
const SRC = join(ROOT, "src");
/** `file: reason` for every bare clock read that is deliberately left. */
const ALLOWED: Record<string, string> = {
"src/lib/rollups/mood-rollups.ts": "computed_at is timestamptz",
"src/lib/rollups/medication-compliance-rollups.ts":
"computed_at is timestamptz",
"src/lib/jobs/job-failures.ts": "pg-boss tables are timestamptz",
"src/lib/jobs/reminder/registrar-shared.ts": "pg-boss tables are timestamptz",
"src/lib/db-compat.ts": "DDL default on a timestamptz column",
};
const SQL_CLOCK =
/(?<![.\w])(?:NOW\(\)|CURRENT_TIMESTAMP\b|clock_timestamp\(\))|(?:(?<!=)[<>]|=)\s*now\(\)\s*(?:$|[,)]|[-+]\s*(?:make_interval|INTERVAL))|\bnow\(\)\s*[-+]\s*(?:make_interval|INTERVAL)/;
const ANCHORED =
/(?:NOW\(\)|CURRENT_TIMESTAMP|clock_timestamp\(\)) AT TIME ZONE 'UTC'/g;
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (name === "generated" || name === "__tests__") continue;
if (statSync(full).isDirectory()) walk(full, out);
else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name))
out.push(full);
}
return out;
}
function offenders(): string[] {
const found: string[] = [];
for (const file of walk(SRC)) {
const rel = relative(ROOT, file);
if (ALLOWED[rel]) continue;
readFileSync(file, "utf8")
.split("\n")
.forEach((line, index) => {
if (/^\s*(\/\/|\*|\/\*|--)/.test(line)) return;
if (SQL_CLOCK.test(line.replace(ANCHORED, ""))) {
found.push(`${rel}:${index + 1}: ${line.trim()}`);
}
});
}
return found;
}
describe("raw SQL clock reads are anchored to UTC", () => {
it("finds the anchored form across the swept files", () => {
// The matcher must see something, or a broken walk would pass.
const passkey = readFileSync(join(SRC, "lib/auth/passkey.ts"), "utf8");
expect(passkey.match(ANCHORED)?.length).toBeGreaterThanOrEqual(3);
expect(SQL_CLOCK.test("AND expires_at > NOW()")).toBe(true);
expect(SQL_CLOCK.test("updated_at = now()")).toBe(true);
expect(SQL_CLOCK.test("const started = now();")).toBe(false);
expect(SQL_CLOCK.test("elapsedMs: () => now() - started,")).toBe(false);
expect(SQL_CLOCK.test("const ends = now() + perCallMs;")).toBe(false);
expect(
SQL_CLOCK.test("deleted_at >= now() - make_interval(days => 1)"),
).toBe(true);
expect(SQL_CLOCK.test("Date.now()")).toBe(false);
});
it("leaves no bare NOW() / CURRENT_TIMESTAMP outside the allowlist", () => {
expect(offenders()).toEqual([]);
});
it("keeps the allowlist honest: every entry still has a bare read", () => {
for (const rel of Object.keys(ALLOWED)) {
const lines = readFileSync(join(ROOT, rel), "utf8")
.replace(ANCHORED, "")
.split("\n");
expect(
lines.some((line) => SQL_CLOCK.test(line)),
rel,
).toBe(true);
}
});
});
+22 -6
View File
@@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record<
// the two status grids used to spell the tint at every call site and now
// name a tone instead, so the marker lives here once.
"src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 },
// v1.42: the readiness inventory's "carries" status icon, one arm of the
// carries / thin / empty tone table, beside its spoken status label.
"src/components/timeline/readiness-inventory.tsx": { "text-success": 1 },
"src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 },
"src/components/admin/api-token-overview-section.tsx": { "text-success": 1 },
"src/components/admin/app-log-preview-section.tsx": {
@@ -97,6 +100,11 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 1,
},
"src/components/admin/glitchtip-section.tsx": { "toast.success": 1 },
// Fires only when the route answered `enqueued: true`; a run already queued
// reads as an info toast instead, and the outcome itself is the status line.
"src/components/admin/measurement-maintenance-card.tsx": {
"toast.success": 1,
},
"src/components/admin/invite-tokens-section.tsx": {
"text-success": 2,
"toast.success": 3,
@@ -173,7 +181,14 @@ const PINNED_AFFORDANCES: Record<
CheckCircle2: 2,
"toast.success": 1,
},
"src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 },
// The Coach message action row: the copy tick after the clipboard write
// resolved, and the remember icon and toast only for the self-context entry
// the tap wrote (`adopted`); an entry that was already there gets a neutral
// icon and `toast.info`.
"src/components/insights/coach-panel/message-actions.tsx": {
"text-success": 2,
"toast.success": 1,
},
// The copy tick on a result table: shown only after the clipboard write
// resolved, so it reports a copy that happened; nothing is written to the
// record.
@@ -183,9 +198,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/insights/coach-panel/history-rail.tsx": {
"toast.success": 1,
},
"src/components/insights/coach-panel/plan-proposal-card.tsx": {
"text-success": 1,
},
"src/components/insights/coach-panel/reminder-suggestion-card.tsx": {
"text-success": 1,
},
@@ -264,6 +276,11 @@ const PINNED_AFFORDANCES: Record<
"src/components/medications/sections/intake-tracking-section.tsx": {
"toast.success": 1,
},
// v1.42.2 — the auto-mark-taken switch confirms the flip, like its
// intake-tracking sibling.
"src/components/medications/sections/auto-mark-taken-section.tsx": {
"toast.success": 1,
},
"src/components/medications/sections/inventory-section.tsx": {
"toast.success": 1,
},
@@ -367,10 +384,9 @@ const PINNED_AFFORDANCES: Record<
"text-success": 2,
},
"src/components/settings/mcp-section.tsx": {
"text-success": 5,
"text-success": 4,
"toast.success": 2,
},
"src/components/settings/modules-section.tsx": { "toast.success": 1 },
"src/components/settings/notification-status-card.tsx": {
CheckCircle2: 2,
"text-success": 1,
@@ -0,0 +1,121 @@
/**
* Structural guard on the focus treatment of text-entry fields.
*
* A text field shows focus by its border alone: `border-input` turns the
* neutral `input-focus` tone (a foreground-based grey, at least 3:1 on the
* background in both themes). No colour ring, no purple. The ring was the
* loudest thing on a form, and for a text field it is on for every mouse
* click too, because `:focus-visible` always matches a field that takes
* typed input. Buttons, links, checkboxes and switches keep their rings;
* they are not in scope here.
*
* The one ring a field still draws is the destructive one on an invalid
* field that has focus (`aria-invalid:focus-visible:ring-[3px]`): its border
* is already red, so the ring is what tells focus apart there.
*
* Two checks:
*
* 1. Every text-entry primitive (and the Coach composer) uses the token.
* 2. Every class string in `src/` that styles a text field (it names
* `border-input`, the focus token, or a `placeholder:` colour) carries no focus ring and no
* primary/ring-coloured focus border. The sweep must find a floor of
* such strings, so a matcher that stops matching fails instead of
* passing on nothing.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..", "..");
const SRC = join(ROOT, "src");
/** Text-entry primitives and the hand-built text fields that own a focus style. */
const TEXT_FIELD_FILES = [
"src/components/ui/input.tsx",
"src/components/ui/textarea.tsx",
"src/components/ui/select.tsx",
"src/components/ui/native-select.tsx",
"src/components/ui/date-field.tsx",
"src/components/ui/time-field.tsx",
"src/components/ui/calendar.tsx",
"src/components/insights/coach-panel/coach-input.tsx",
"src/components/cycle/cycle-settings.tsx",
"src/components/records/about-me-note-manager.tsx",
"src/components/records/conditions-manager.tsx",
"src/components/records/allergy-free-text-note.tsx",
];
const FOCUS_VARIANT = /(^|:)(focus|focus-visible|focus-within|has-focus)(:|$)/;
const BANNED_UTILITY =
/^(ring(-\[[^\]]+\]|-\d+)?$|ring-(ring|primary|offset)|border-(ring|primary)|outline-(ring|primary))/;
/** Focus tokens in a class string that put a ring or a colour border on focus. */
function bannedFocusTokens(classString: string): string[] {
return classString.split(/\s+/).filter((token) => {
if (!token.includes(":")) return false;
const parts = token.split(":");
const utility = parts.pop()!;
const variants = parts.join(":");
if (!FOCUS_VARIANT.test(variants)) return false;
// The invalid field's destructive focus ring is the documented exception.
if (/(^|:)aria-invalid(:|$)/.test(variants)) return false;
return BANNED_UTILITY.test(utility);
});
}
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (name === "generated" || name === "__tests__") continue;
if (statSync(path).isDirectory()) walk(path, out);
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name))
out.push(path);
}
return out;
}
const STRING_LITERAL = /"([^"\n]*)"|`([^`]*)`|'([^'\n]*)'/g;
const TEXT_FIELD_MARKER =
/(^|\s)(border-input|placeholder:text-)|border-input-focus/;
describe("text-field focus", () => {
it("the detector flags a ring and a purple border, and spares the exceptions", () => {
expect(
bannedFocusTokens(
"focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-within:border-primary/50 focus-within:ring-2 has-focus:border-ring focus-visible:ring-offset-2",
),
).toHaveLength(6);
expect(
bannedFocusTokens(
"focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px] focus-visible:outline-none aria-invalid:ring-destructive/20",
),
).toEqual([]);
});
it.each(TEXT_FIELD_FILES)("%s uses the neutral focus border", (file) => {
const source = readFileSync(join(ROOT, file), "utf8");
expect(source).toContain("border-input-focus");
expect(source).not.toMatch(/ring-primary/);
});
it("no class string that styles a text field draws a focus ring", () => {
const offenders: string[] = [];
let fieldStrings = 0;
for (const file of walk(SRC)) {
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(STRING_LITERAL)) {
const literal = match[1] ?? match[2] ?? match[3] ?? "";
if (!TEXT_FIELD_MARKER.test(literal)) continue;
fieldStrings++;
const bad = bannedFocusTokens(literal);
if (bad.length > 0)
offenders.push(`${relative(ROOT, file)}: ${bad.join(" ")}`);
}
}
// The primitives alone account for more than this; fewer means the
// matcher went blind, not that the fields went away.
expect(fieldStrings).toBeGreaterThanOrEqual(10);
expect(offenders).toEqual([]);
});
});
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
/**
* The code-side twin of `i18n-no-dot-separator.test.ts`: a string the code
* composes itself (an aria-label `${action} — ${name}`, a page title, a
* Coach prefill) carries no spaced em or en dash either. The bundles say
* "Move up: Ramipril" and "No connection: changes can't be saved", so a
* label put together in a component says it the same way.
*
* The matcher looks at string and template literals on one line, outside
* comments: a quote or backtick, then text, a space, U+2014 or U+2013, a
* space. A range written in code ("10–12", `${from}–${to}`) has no spaces
* around its dash and is not matched. A template literal broken across lines
* escapes the one-line matcher, which is this guard's known limit.
*/
const ROOT = join(__dirname, "..");
const DIRS = ["components", "app"].map((d) => join(ROOT, d));
const LITERAL = /(["'`])[^"'`]* [—–] [^"'`]*\1/;
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
if (name === "__tests__") continue;
walk(path, out);
} else if (/\.(ts|tsx)$/.test(name) && !/\.test\./.test(name)) {
out.push(path);
}
}
return out;
}
const isComment = (line: string) => /^\s*(\/\/|\*|\/\*|\{\/\*)/.test(line);
describe("composed UI strings carry no spaced dash", () => {
const files = DIRS.flatMap((d) => walk(d));
it("walks the component and app trees", () => {
expect(files.length).toBeGreaterThan(500);
});
it("has no ' — ' or ' – ' inside a string or template literal", () => {
const offenders: string[] = [];
for (const file of files) {
readFileSync(file, "utf8")
.split("\n")
.forEach((line, i) => {
if (isComment(line)) return;
const code = line.replace(/\s\/\/\s.*$/, "");
if (LITERAL.test(code)) {
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
}
});
}
expect(offenders).toEqual([]);
});
it("catches the pattern it is written for", () => {
expect(LITERAL.test("aria-label={`${a} — ${b}`}")).toBe(true);
expect(LITERAL.test('title: "About – HealthLog"')).toBe(true);
expect(LITERAL.test("`${from}–${to}`")).toBe(false);
});
});
@@ -390,7 +390,7 @@ const UNIT_LITERAL_ALLOWLIST: Record<
"the reply checker's list of unit tokens it recognises in model prose",
},
"lib/ai/coach/eval/golden-cases.ts": {
lines: 3,
lines: 5,
reason: "evaluation fixtures: scripted replies and ideal answers",
},
"lib/ai/coach/eval/red-team.ts": {
@@ -51,18 +51,21 @@ describe("User column backup classification", () => {
);
});
// The person's own data, sealed at rest for privacy rather than a secret
// that grants anything: the insurance number, and since v1.42 the home
// location the environment module resolves weather against.
const SEALED_SETTINGS = ["insuranceNumberEncrypted", "homeLocationEncrypted"];
it("carries no credential-shaped column as a setting", () => {
// The insurance number is the one sealed setting: it is the person's own
// data, sealed at rest for privacy, not a secret that grants anything.
const sealedSettings = ACCOUNT_SETTING_COLUMNS.filter((column) =>
/Encrypted$|Token|Secret|password|totp|oidc/i.test(column),
);
expect(sealedSettings).toEqual(["insuranceNumberEncrypted"]);
expect(sealedSettings.sort()).toEqual([...SEALED_SETTINGS].sort());
});
it("never classifies a sealed column other than the insurance number as anything but a credential", () => {
it("never classifies a sealed column other than the sealed settings as anything but a credential", () => {
const sealed = columns.filter(
(c) => /Encrypted$/.test(c) && c !== "insuranceNumberEncrypted",
(c) => /Encrypted$/.test(c) && !SEALED_SETTINGS.includes(c),
);
expect(sealed.length).toBeGreaterThan(20);
expect(sealed.filter((c) => classified[c] !== "CREDENTIAL")).toEqual([]);
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(84);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -107,6 +107,15 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
writeFile: "lib/measurements/import-apple-health-export.ts",
metadataKey: "activityType",
},
{
provider: "Health Connect export",
mapper: "mapHealthConnectSportType",
mapperFile: "lib/import/health-connect/mapper.ts",
captureFile: "lib/import/health-connect/import.ts",
captureField: "exercise_type",
writeFile: "lib/import/health-connect/import.ts",
metadataKey: "healthConnectExerciseType",
},
{
provider: "Fitbit",
mapper: "mapFitbitSportType",
@@ -42,6 +42,17 @@ describe("dashboard hero — page wiring", () => {
);
});
it("hides the top card, its skeleton and its error card only on an explicit false", () => {
// One gate in front of the whole three-way branch, so switching the card
// off leaves no reserve and no retry card where it stood.
const gate = src.indexOf("layout.todayCardVisible === false ? null :");
expect(gate).toBeGreaterThan(-1);
const hero = src.indexOf("<TodayHero", gate);
const skeleton = src.indexOf("<TodayHeroSkeleton", gate);
expect(hero).toBeGreaterThan(gate);
expect(skeleton).toBeGreaterThan(hero);
});
it("no longer references the retired legacy hero", () => {
expect(src).not.toContain("DashboardHero");
expect(src).not.toContain("DashboardHeroSkeleton");
@@ -1,5 +1,6 @@
/**
* Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`.
* Unit tests for the chunk-error auto-reload guard in
* `src/lib/pwa/chunk-reload.ts` (both error boundaries call it).
*
* The guard is keyed on the running shell's build version: one reload
* attempt per BROKEN SHELL, not one per session. After a successful
@@ -10,7 +11,11 @@
import { describe, expect, it } from "vitest";
import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error";
import {
chunkReloadGuardValue,
isChunkLoadError,
shouldAttemptChunkReload,
} from "@/lib/pwa/chunk-reload";
describe("shouldAttemptChunkReload", () => {
it("reloads when no attempt is recorded", () => {
@@ -39,3 +44,39 @@ describe("shouldAttemptChunkReload", () => {
expect(shouldAttemptChunkReload("unversioned", "")).toBe(false);
});
});
describe("isChunkLoadError", () => {
it.each([
[{ name: "ChunkLoadError", message: "x" }],
[{ name: "Error", message: "Loading chunk 123 failed." }],
[{ name: "Error", message: "Loading CSS chunk 7 failed." }],
[
{
name: "Error",
message: "Failed to load chunk /_next/static/chunks/a.js",
},
],
[
{
name: "TypeError",
message: "Failed to fetch dynamically imported module: /x.js",
},
],
// WebKit and Firefox word a failed dynamic import differently.
[{ name: "TypeError", message: "Importing a module script failed." }],
[
{
name: "TypeError",
message: "error loading dynamically imported module: /x.js",
},
],
])("recognises %j", (err) => {
expect(isChunkLoadError(err)).toBe(true);
});
it("leaves other errors to the error page", () => {
expect(
isChunkLoadError({ name: "TypeError", message: "x is undefined" }),
).toBe(false);
});
});
+29 -13
View File
@@ -1,6 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
import { resolveIntlLocale } from "@/lib/format-locale";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
* v1.4.27 B3 — Public about / credits page.
*
@@ -40,7 +44,7 @@ export const dynamic = "force-dynamic";
const LAST_UPDATED = "2026-08-02";
export const metadata: Metadata = {
title: "About — HealthLog",
title: "About | HealthLog",
description:
"Open-source credits and third-party data attributions for the HealthLog project.",
robots: { index: true, follow: true },
@@ -60,14 +64,24 @@ function Section({
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h2>
<div className="text-muted-foreground space-y-3 text-sm leading-relaxed md:text-base">
<div className="text-foreground space-y-3 text-sm leading-relaxed md:text-base">
{children}
</div>
</section>
);
}
export default function AboutPage() {
export default async function AboutPage() {
// The headings, the date and the sign-in link follow the reader's locale;
// the body is the attribution text the licences ask for and stays as
// written.
const locale = await resolveServerLocale();
const { t } = getServerTranslator(locale);
const lastUpdated = new Intl.DateTimeFormat(resolveIntlLocale(locale), {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${LAST_UPDATED}T12:00:00Z`));
return (
<div className="bg-background text-foreground min-h-dvh">
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur">
@@ -82,7 +96,7 @@ export default function AboutPage() {
href="/auth/login"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-sm"
>
Sign in
{t("auth.login")}
</Link>
</div>
</header>
@@ -99,17 +113,19 @@ export default function AboutPage() {
>
<div className="space-y-3">
<p className="text-muted-foreground text-xs tracking-wider uppercase">
About
{t("aboutPage.eyebrow")}
</p>
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
About HealthLog
{t("aboutPage.title")}
</h1>
<p className="text-muted-foreground text-sm">
Last updated: {LAST_UPDATED}
<time dateTime={LAST_UPDATED}>
{t("aboutPage.lastUpdated", { date: lastUpdated })}
</time>
</p>
</div>
<Section id="project" title="Project">
<Section id="project" title={t("aboutPage.project")}>
<p>
HealthLog is an open-source, self-hostable personal-health-tracking
application. The source code lives at{" "}
@@ -117,7 +133,7 @@ export default function AboutPage() {
href="https://github.com/MBombeck/HealthLog"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
github.com/MBombeck/HealthLog
</a>{" "}
@@ -127,7 +143,7 @@ export default function AboutPage() {
</p>
</Section>
<Section id="credits" title="Credits">
<Section id="credits" title={t("aboutPage.credits")}>
<p>
HealthLog stands on a number of open-source libraries and public
data sources. The list below covers the third-party assets that ship
@@ -145,7 +161,7 @@ export default function AboutPage() {
href="https://www.maxmind.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
www.maxmind.com
</a>
@@ -164,7 +180,7 @@ export default function AboutPage() {
href="https://www.maxmind.com/en/geolite/eula"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
GeoLite End User License Agreement
</a>
@@ -173,7 +189,7 @@ export default function AboutPage() {
href="https://creativecommons.org/licenses/by-sa/4.0/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
Creative Commons Attribution-ShareAlike 4.0 International License
</a>{" "}
@@ -132,8 +132,6 @@ const mockData = {
] satisfies AchievementProgress[],
metrics: {
totalTakenIntakes: 1,
overIntakeCount: 0,
skippedIntakeCount: 0,
bmiGreenStreak: 0,
bpGreenStreak: 0,
pulseGreenStreak: 0,
+40 -18
View File
@@ -35,7 +35,7 @@ import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import {
@@ -81,12 +81,13 @@ const CATEGORY_LABEL_KEY: Record<AchievementCategory, string> = {
function formatMetric(
format: AchievementProgress["format"],
value: number,
t: (key: string, params?: Record<string, string | number>) => string,
i18n: Pick<ReturnType<typeof useTranslations>, "t" | "tCount">,
) {
if (format === "percent")
return t("achievements.metricPercent", { count: value });
if (format === "days") return t("achievements.metricDays", { count: value });
return t("achievements.metricCount", { count: value });
return i18n.t("achievements.metricPercent", { count: value });
// "1 Tag", not "1 Tage": the day count takes the plural form.
if (format === "days") return i18n.tCount("achievements.metricDays", value);
return i18n.t("achievements.metricCount", { count: value });
}
/**
@@ -127,9 +128,11 @@ export function groupByCategory(
interface AchievementCardProps {
achievement: AchievementProgress;
t: ReturnType<typeof useTranslations>["t"];
tCount: ReturnType<typeof useTranslations>["tCount"];
}
function AchievementCard({ achievement, t }: AchievementCardProps) {
function AchievementCard({ achievement, t, tCount }: AchievementCardProps) {
const i18n = { t, tCount };
const Icon = iconMap[achievement.icon] ?? Star;
const unlocked = achievement.unlocked;
@@ -175,7 +178,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
className={
unlocked
? "border-primary/30 from-primary/8 to-primary/0 rounded-xl border bg-gradient-to-br p-3"
: "border-border bg-card/50 rounded-xl border p-3 opacity-70"
: "border-border bg-card/50 rounded-xl border p-3"
}
>
<div className="mb-2 flex items-start justify-between gap-2">
@@ -191,7 +194,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold">{t(achievement.titleKey)}</h3>
<p className="text-muted-foreground line-clamp-2 text-xs">
<p className="text-muted-foreground text-xs">
{t(achievement.descriptionKey)}
</p>
</div>
@@ -215,8 +218,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
<div className="mt-2 space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("achievements.criterionHint", {
current: formatMetric(achievement.format, achievement.current, t),
target: formatMetric(achievement.format, achievement.target, t),
current: formatMetric(
achievement.format,
achievement.current,
i18n,
),
target: formatMetric(
achievement.format,
achievement.target,
i18n,
),
})}
</p>
{/* v1.4.33 IW9 — aria-label so the bar carries an accessible name. */}
@@ -224,8 +235,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
value={achievement.progressPercent}
className="h-1.5"
aria-label={t("achievements.criterionHint", {
current: formatMetric(achievement.format, achievement.current, t),
target: formatMetric(achievement.format, achievement.target, t),
current: formatMetric(
achievement.format,
achievement.current,
i18n,
),
target: formatMetric(
achievement.format,
achievement.target,
i18n,
),
})}
/>
</div>
@@ -259,7 +278,9 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
export default function AchievementsPage() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
const { t, tCount } = useTranslations();
const fmt = useFormatters();
const i18n = { t, tCount };
// v1.18.0 — the achievements module gate. When the account has the
// module turned off the whole page disappears: an empty state stands in
@@ -335,9 +356,9 @@ export default function AchievementsPage() {
{t("achievements.points")}
</p>
<p className="text-4xl leading-none font-bold md:text-5xl">
{summary?.earnedPoints ?? 0}
{fmt.integer(summary?.earnedPoints ?? 0)}
<span className="text-muted-foreground ml-2 text-base font-medium">
/ {summary?.totalPoints ?? 0}
/ {fmt.integer(summary?.totalPoints ?? 0)}
</span>
</p>
<Progress
@@ -373,7 +394,7 @@ export default function AchievementsPage() {
<p className="text-base font-semibold">
{t(summary.nextAchievement.titleKey)}
</p>
<p className="text-muted-foreground line-clamp-2 text-xs">
<p className="text-muted-foreground text-xs">
{t(summary.nextAchievement.descriptionKey)}
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
@@ -381,13 +402,13 @@ export default function AchievementsPage() {
{formatMetric(
summary.nextAchievement.format,
summary.nextAchievement.current,
t,
i18n,
)}{" "}
/{" "}
{formatMetric(
summary.nextAchievement.format,
summary.nextAchievement.target,
t,
i18n,
)}{" "}
({summary.nextAchievement.progressPercent}%)
</p>
@@ -449,6 +470,7 @@ export default function AchievementsPage() {
key={achievement.id}
achievement={achievement}
t={t}
tCount={tCount}
/>
))}
</div>
+16 -4
View File
@@ -5,7 +5,7 @@
* a given slug. Lives next to the route page so the shell can stay a
* server component while the auth-gated body remains a client component.
*
* This is the equivalent of `SECTION_COMPONENTS` in
* This is the equivalent of `SettingsSectionLoader` for
* `src/app/settings/[section]/page.tsx`, lifted to a separate file so
* the route page can be a server component (Settings can keep
* `"use client"`-leaning components in its page because none of them
@@ -36,6 +36,9 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit
import { RemindersSection } from "@/components/admin/reminders-section";
import { ServicesSection } from "@/components/admin/services-section";
import { SystemStatusSection } from "@/components/admin/system-status-section";
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
import { InviteTokensSection } from "@/components/admin/invite-tokens-section";
import { UserManagementSection } from "@/components/admin/user-management-section";
import type { AdminSectionSlug } from "@/components/admin/section-slugs";
@@ -58,9 +61,18 @@ export function AdminSectionRenderer({
switch (slug) {
case "system-status":
return (
<SectionFrame>
<SystemStatusSection />
</SectionFrame>
// The status cards grow as their reads land (host metrics, workers,
// queues); revealed together so the maintenance card below is not
// pushed down the page three times while they load.
<RevealWhenSettled
fallback={<Skeleton className="h-[40rem] w-full rounded-xl" />}
>
<SectionFrame>
<SystemStatusSection />
{/* v1.42 — the table maintenance after the tombstone purge. */}
<MeasurementMaintenanceCard />
</SectionFrame>
</RevealWhenSettled>
);
case "general":
return (
+7 -5
View File
@@ -102,6 +102,11 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
capabilities: ["coach"],
why: "POST streams a Coach reply from a model. The history GET in the same file asks nothing.",
},
"src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts": {
kind: "mixed",
capabilities: ["coach"],
why: "The trail's reasoning text is model-written and served only while the Coach's text may be shown; otherwise 200 with trail null and the ai state.",
},
"src/app/api/insights/generate/route.ts": {
kind: "action",
capabilities: ["briefing"],
@@ -132,11 +137,6 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
capabilities: ["coach"],
why: "The unread signal of a Coach that is unavailable is the quiet empty shape, never a refusal.",
},
"src/app/api/insights/coach/seeded-question/route.ts": {
kind: "mixed",
capabilities: ["coach"],
why: "The opener exists only to open the Coach: `signal: null` while it is unavailable.",
},
"src/app/api/coach/about-me/route.ts": {
kind: "mixed",
capabilities: ["aboutMeQuestions"],
@@ -210,6 +210,8 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
"src/app/api/insights/ecg/[id]/route.ts": "One device recording's waveform.",
"src/app/api/insights/rhythm-events/route.ts":
"The device's own flagged events, verbatim.",
"src/app/api/insights/score-history/route.ts":
"A score's stored or computed daily values; no model text.",
"src/app/api/insights/cards/route.ts":
"Rule alerts from the threshold engine; `provider` is `rules`.",
"src/app/api/insights/correlations/route.ts":
@@ -67,6 +67,10 @@ const DELIBERATELY_OPEN: Record<string, { count: number; reason: string }> = {
reason: "hand-built query object",
},
"insights/derived/route.ts": { count: 1, reason: "hand-built query object" },
"insights/score-history/route.ts": {
count: 1,
reason: "hand-built query object",
},
"insights/metric-status/route.ts": {
count: 1,
reason: "hand-built query object",
@@ -119,6 +123,14 @@ const DELIBERATELY_OPEN: Record<string, { count: number; reason: string }> = {
count: 1,
reason: "read filter over whole searchParams",
},
"medications/compliance/route.ts": {
count: 1,
reason: "read filter over whole searchParams",
},
"mood/insights/route.ts": {
count: 1,
reason: "read filter over whole searchParams",
},
// ---------------------------------------------------------------
// 3. Native-client ingest and device registration. This is where the
@@ -193,6 +193,11 @@ const MODULE_ROUTE_TREES_BY_KEY: Readonly<
// data-layer-gated: every `/api/vaccinations/*` route is raw CRUD over the
// person's own doses, so all six are EXEMPT below under the same reasoning.
vaccinations: ["src/app/api/vaccinations"],
// v1.42 (#613) — the life timeline. The timeline reads and the readiness
// inventory gate on `timeline`; the life-event row store beside them is
// EXEMPT below under the data-layer reasoning. The day view is not this
// module's: it is core and lives under `/api/day`, which no key owns.
timeline: ["src/app/api/timeline", "src/app/api/life-events"],
};
/** Every declared tree, de-duplicated — the set this inventory walks. */
@@ -332,7 +337,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// being available (the GDPR reason), and a timestamp on one's own row is
// data too.
"src/app/api/insights/coach/seen/route.ts",
"src/app/api/insights/coach/facts/route.ts",
// `insights/coach/facts/route.ts` is not listed, by the rule below: it
// lists and erases without the Coach gate but keeps it on POST (the
// remember button saves to the Coach's memory, which is Coach use).
"src/app/api/insights/coach/facts/[id]/route.ts",
// The stored plans list. Its sibling `[id]` route keeps the Coach gate on
// PATCH (confirming a proposal is Coach use) and erases without it.
@@ -400,12 +407,25 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/vaccinations/[id]/route.ts",
"src/app/api/vaccinations/[id]/restore/route.ts",
"src/app/api/vaccinations/[id]/links/route.ts",
// v1.42 (#1005) — the record's own vaccine definitions. Same posture: a
// restore must bring back the definitions its doses point at with the
// surface hidden.
"src/app/api/vaccinations/custom/route.ts",
"src/app/api/vaccinations/custom/[id]/route.ts",
// The booster mint and the upload suggestion are the same posture: a restore
// or an import that arms a booster, or a document review that files a scan,
// must keep working with the surface hidden, so the data routes stay exempt
// while the nav entry, the picker and the report leaf hide.
"src/app/api/vaccinations/[id]/booster/route.ts",
"src/app/api/vaccinations/suggest/route.ts",
// ── DATA LAYER (timeline) ─────────────────────────────────────────
// v1.42 (#613) — the record's life events. Raw CRUD over the person's own
// rows: the module decides whether they SURFACE (the timeline, the day's
// life-event line, the capture entry), not whether the store accepts them.
// A restore must bring them back with the module off, and re-enabling must
// find every event where it was.
"src/app/api/life-events/route.ts",
"src/app/api/life-events/[id]/route.ts",
// ── INFRA / CREDENTIALS (mcp) ─────────────────────────────────────
// The remote MCP endpoint itself is `src/app/mcp/route.ts`, outside `/api`,
// and it carries the gate: an account with the module off gets a 404 there
@@ -0,0 +1,109 @@
/**
* `/api/account/handover-decision` — the new owner's final word on each former
* Guardian after claiming a managed profile (v1.42, #959).
*
* The claim applies the access the issuing Guardian proposed, so the record
* is never without a decision. This is where the owner sees it and decides:
*
* - `GET` answers the decision that is waiting, or `pending: null`.
* - `POST` records it. A Guardian the body leaves out keeps what the claim
* gave them; after this, the ordinary Settings → Shared access panel is
* where access changes.
*
* A grant-management surface, resolved like the others (`/api/account/
* grants/**`): bare `requireAuth()`, which refuses under an acting-account
* switch and refuses a narrow Bearer scope. The owner is the session user; no
* party to any grant comes from the body, only the claim-time grant id that
* names which former Guardian a decision is about.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
decideHandover,
HandoverError,
readPendingHandoverDecision,
} from "@/lib/managed-profiles/handover";
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
import { prisma } from "@/lib/db";
import { handoverDecisionSchema } from "@/lib/validations/managed-profile-handover";
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
const pending = await readPendingHandoverDecision(user.id);
annotate({
action: { name: "managed_profile.handover.decision_read" },
meta: { pending: pending !== null },
});
return apiSuccess({
pending: pending
? {
claimedAt: pending.claimedAt.toISOString(),
guardians: pending.guardians,
}
: null,
});
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = handoverDecisionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
let result;
try {
result = await decideHandover({
userId: user.id,
decisions: parsed.data.decisions.map((d) => ({
grantId: d.grantId,
access: d.access,
})),
});
} catch (error) {
if (error instanceof HandoverError) {
if (error.code === "unknown_guardian") {
return apiError(
"A decision names somebody who was not a Guardian",
422,
{
errorCode: "managed_profile.handover.unknown_guardian",
},
);
}
return apiError("There is no handover decision waiting", 409, {
errorCode: "managed_profile.handover.no_pending",
});
}
throw error;
}
if (result.changed.length > 0) {
const self = await prisma.user.findUnique({
where: { id: user.id },
select: { displayName: true },
});
notifyGuardiansOfHandover(
"changed",
self?.displayName ?? null,
result.changed,
);
}
annotate({
action: { name: "managed_profile.handover.decided" },
meta: { changed: result.changed.length },
});
return apiSuccess({ decided: true, changed: result.changed.length });
});
@@ -38,7 +38,6 @@ import {
type StreamedBackup,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import { parseBackupPayload } from "@/lib/validations/backup";
export const dynamic = "force-dynamic";
@@ -108,6 +107,8 @@ export const GET = apiHandler(
let streamed: StreamedBackup;
try {
streamed = await readStreamedBackup(source);
// Loaded on first use, not at import (see `backup-summary.ts`).
const { parseBackupPayload } = await import("@/lib/validations/backup");
parseBackupPayload(streamed.raw);
} catch (err) {
const readFailure = isStoredBackupReadError(err);
@@ -84,7 +84,7 @@ const handler = apiHandler(
details: { reason: "missing_confirmation", backupId: id },
});
return apiError(
"Confirmation token missing — replacing an account's data requires confirm: 'RESTORE'",
"Confirmation token missing. Replacing an account's data requires confirm: 'RESTORE'",
422,
);
}
@@ -67,14 +67,16 @@ const summarizeMock = vi.fn();
const compatibleMock = vi.fn((version: string) => version !== "99");
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
isCompatibleSchemaVersion: (version: string) => compatibleMock(version),
// The measurements are read as a stream and counted one by one.
BACKUP_SCHEMA_VERSION: "2",
backupMeasurementSchema: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
}));
vi.mock("@/lib/validations/backup-summary", () => ({
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
isCompatibleSchemaVersion: (version: string) => compatibleMock(version),
BACKUP_SCHEMA_VERSION: "2",
}));
import { GET } from "../route";
import { storedCopyIdentity } from "@/lib/export/backup-preview";
@@ -59,9 +59,8 @@ import {
import { annotate } from "@/lib/logging/context";
import {
isCompatibleSchemaVersion,
parseBackupPayload,
summarizeBackup,
} from "@/lib/validations/backup";
} from "@/lib/validations/backup-summary";
export const dynamic = "force-dynamic";
@@ -123,6 +122,8 @@ async function readPreview(
try {
const streamed = await readStreamedBackup(source);
// Loaded on first use, not at import (see `backup-summary.ts`).
const { parseBackupPayload } = await import("@/lib/validations/backup");
const payload = parseBackupPayload(streamed.raw);
const summary = {
...summarizeBackup(payload),
@@ -19,16 +19,24 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const runningSinceMock = vi.fn(
async (_queue: string): Promise<string | null> => null,
);
vi.mock("@/lib/jobs/job-failures", () => ({
readLastQueueRun: vi.fn(async () => null),
readQueueRunningSince: (queue: string) => runningSinceMock(queue),
}));
const queryRawMock = vi.fn();
const findManyMock = vi.fn();
const attemptsMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: (...a: unknown[]) => queryRawMock(...a),
user: { findMany: (...a: unknown[]) => findManyMock(...a) },
backupPassAttempt: {
findMany: (...a: unknown[]) => attemptsMock(...a),
},
offhostPurgeRequest: {
count: async () => 2,
findFirst: async () => ({
@@ -41,6 +49,7 @@ vi.mock("@/lib/db", () => ({
const configuredMock = vi.fn(() => true);
vi.mock("@/lib/jobs/offhost-backup", () => ({
OFFHOST_BACKUP_QUEUE: "data-backup-offhost",
offhostBackupConfigured: () => configuredMock(),
probeOffhostLifecycle: async () => ({
state: "missing",
@@ -64,6 +73,8 @@ beforeEach(() => {
configuredMock.mockReturnValue(true);
queryRawMock.mockResolvedValue([]);
findManyMock.mockResolvedValue([]);
attemptsMock.mockResolvedValue([]);
runningSinceMock.mockResolvedValue(null);
});
describe("GET /api/admin/backups — off-host freshness", () => {
@@ -226,3 +237,51 @@ describe("GET /api/admin/backups — off-host freshness", () => {
});
});
});
describe("GET /api/admin/backups — a run in progress and one a process died under", () => {
it("names the account an earlier run never came back from, and says since when one runs", async () => {
const runningSince = "2026-10-04T02:00:00.000Z";
runningSinceMock.mockImplementation(async (queue: string) =>
queue === "data-backup" ? runningSince : null,
);
findManyMock.mockResolvedValue([
{ id: "u1", username: "account-one", offhostBackupState: null },
{ id: "u2", username: "account-two", offhostBackupState: null },
]);
attemptsMock.mockImplementation(
async ({ where }: { where: { pass: string } }) =>
where.pass === "data-backup"
? [
// Died under an earlier run.
{
userId: "u1",
startedAt: new Date("2026-09-27T02:10:00.000Z"),
finishedAt: new Date("2026-09-20T02:30:00.000Z"),
},
// The account the run in progress is on now.
{
userId: "u2",
startedAt: new Date("2026-10-04T02:05:00.000Z"),
finishedAt: null,
},
]
: [],
);
const body = await read();
expect(body.scheduleActivity).toEqual({
runningSince,
interrupted: [
{
userId: "u1",
username: "account-one",
startedAt: "2026-09-27T02:10:00.000Z",
},
],
});
expect(body.offhost.activity).toEqual({
runningSince: null,
interrupted: [],
});
});
});
+16 -1
View File
@@ -25,9 +25,14 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
import { readLastQueueRun } from "@/lib/jobs/job-failures";
import {
readLastQueueRun,
readQueueRunningSince,
} from "@/lib/jobs/job-failures";
import { readBackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
import {
OFFHOST_BACKUP_QUEUE,
offhostBackupConfigured,
probeOffhostLifecycle,
} from "@/lib/jobs/offhost-backup";
@@ -177,6 +182,14 @@ export const GET = apiHandler(async () => {
lastAttemptAt: { not: null },
},
};
const [weeklyRunningSince, offhostRunningSince] = await Promise.all([
readQueueRunningSince(DATA_BACKUP_QUEUE),
readQueueRunningSince(OFFHOST_BACKUP_QUEUE),
]);
const [scheduleActivity, offhostActivity] = await Promise.all([
readBackupPassActivity(prisma, DATA_BACKUP_QUEUE, weeklyRunningSince),
readBackupPassActivity(prisma, OFFHOST_BACKUP_QUEUE, offhostRunningSince),
]);
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
prisma.offhostPurgeRequest.count({ where: stillInBucket }),
prisma.offhostPurgeRequest.findFirst({
@@ -201,8 +214,10 @@ export const GET = apiHandler(async () => {
lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE),
now,
}),
scheduleActivity,
offhost: {
configured,
activity: offhostActivity,
periodHours: OFFHOST_BACKUP_PERIOD_HOURS,
// An unconfigured host has nothing to list, and the card says so rather
// than painting an empty table that reads as "no problems".
+18 -5
View File
@@ -61,15 +61,13 @@ import { storeBackupBlob } from "@/lib/export/store-backup-blob";
import { StreamedBackupInvalidError } from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import type { BackupPayload } from "@/lib/validations/backup";
import {
BACKUP_SCHEMA_VERSION,
backupMeasurementSchema,
backupPayloadSchema,
isCompatibleSchemaVersion,
summarizeBackup,
type BackupPayload,
type BackupSummary,
} from "@/lib/validations/backup";
} from "@/lib/validations/backup-summary";
export const dynamic = "force-dynamic";
@@ -221,6 +219,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
let measurementCount = 0;
let owner: { id: string; username: string } | null = null;
// Filled while the file is read; the preview is the summary this route
// answers with and the key uses it already holds (`backup-preview.ts`).
const keys = new BackupKeyIdCollector();
let created: { id: string };
try {
created = await storeBackupBlob(
@@ -229,6 +230,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
userId: admin.id,
type: uploadType,
ownerAfterRead: () => owner!.id,
preview: () =>
payload
? {
summary: {
...summarizeBackup(payload),
measurements: measurementCount,
},
keys,
}
: null,
},
async (write) => {
// One pass: every chunk is stored as it is checked, and a check that
@@ -239,8 +250,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
yield chunk;
}
}
// Loaded on first use, not at import (see `backup-summary.ts`).
const { backupMeasurementSchema, backupPayloadSchema } =
await import("@/lib/validations/backup");
let firstWithoutId: number | null = null;
const keys = new BackupKeyIdCollector();
let scanned;
try {
scanned = await scanBackupJson(storedAsRead(), {
@@ -45,7 +45,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
userCode = decoded.userCode;
} catch {
cookieStore.delete("central_codex_device");
return apiError("Invalid device-auth state — restart the flow", 400);
return apiError("Invalid device-auth state. Restart the flow", 400);
}
let result;
@@ -55,7 +55,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { central_codex_device_poll_error: message } });
return apiError(
"Central Codex device-poll failed — please restart the flow",
"Central Codex device-poll failed. Please restart the flow",
502,
);
}
@@ -22,6 +22,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_V2_QUEUE,
APPLE_HEALTH_IMPORT_KIND,
APPLE_HEALTH_IMPORT_PARSER_REVISION,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
type AppleHealthImportPayload,
@@ -31,6 +32,7 @@ import {
createImportJobUnlessBusy,
discardStagedUpload,
IMPORT_BUSY_CODE,
IMPORT_BUSY_MESSAGE,
} from "@/lib/import/apple-health-staging";
import { unlink } from "node:fs/promises";
@@ -128,6 +130,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const existing = await prisma.importJob.findFirst({
where: {
userId: targetUser.id,
kind: APPLE_HEALTH_IMPORT_KIND,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
status: { not: "failed" },
@@ -170,11 +173,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if ("busy" in staged) {
await discardStagedUpload(uploaded.filePath);
return apiError(
"An Apple Health import is already running for this account. Wait for it to finish, then upload again.",
409,
{ errorCode: IMPORT_BUSY_CODE, jobId: staged.busy.id },
);
return apiError(IMPORT_BUSY_MESSAGE, 409, {
errorCode: IMPORT_BUSY_CODE,
jobId: staged.busy.id,
});
}
const importJob = staged.created;
@@ -0,0 +1,143 @@
/**
* `GET /api/admin/maintenance/measurements` — the status the admin card
* starts and follows the run with. Admin-only, and a web-only deployment
* (no pg-boss schema) answers `available: false` instead of failing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { runStateOf } from "@/lib/jobs/measurement-maintenance-status";
const ADMIN = {
session: { id: "s-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
};
const USER = {
session: { id: "s-2", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "bob", role: "USER" as const },
};
const get = () =>
(GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest("http://localhost/api/admin/maintenance/measurements"),
);
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/admin/maintenance/measurements", () => {
it("is refused to anyone but an admin", async () => {
vi.mocked(getSession).mockResolvedValue(USER as never);
expect((await get()).status).toBe(403);
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it("reports a running pass, the purge and the table size", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
vi.mocked(prisma.$queryRaw)
// sizes
.mockResolvedValueOnce([
{ table_bytes: BigInt(5_000_000_000), index_bytes: BigInt(2e9) },
] as never)
// newest run
.mockResolvedValueOnce([
{
state: "active",
created_on: new Date("2026-10-08T08:00:00Z"),
started_on: new Date("2026-10-08T08:00:05Z"),
completed_on: null,
outcome: null,
},
] as never)
// purge queue
.mockResolvedValueOnce([{ n: 0 }] as never);
const body = await (await get()).json();
expect(body.data).toEqual({
available: true,
purgePending: false,
run: {
state: "running",
requestedAt: "2026-10-08T08:00:00.000Z",
startedAt: "2026-10-08T08:00:05.000Z",
finishedAt: null,
outcome: null,
},
sizes: { tableBytes: 5_000_000_000, indexBytes: 2_000_000_000 },
});
});
it("says when a finished run refused because the purge was still working", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{ table_bytes: BigInt(1), index_bytes: BigInt(1) },
] as never)
.mockResolvedValueOnce([
{
state: "completed",
created_on: new Date("2026-10-08T08:00:00Z"),
started_on: new Date("2026-10-08T08:00:01Z"),
completed_on: new Date("2026-10-08T08:00:02Z"),
outcome: "refused_purge_running",
},
] as never)
.mockResolvedValueOnce([{ n: 1 }] as never);
const body = await (await get()).json();
expect(body.data.purgePending).toBe(true);
expect(body.data.run.outcome).toBe("refused_purge_running");
});
it("answers available: false where there is no queue to ask", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{ table_bytes: BigInt(10), index_bytes: BigInt(4) },
] as never)
.mockRejectedValue(new Error('relation "pgboss.job" does not exist'));
const res = await get();
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({
available: false,
purgePending: false,
run: null,
sizes: { tableBytes: 10, indexBytes: 4 },
});
});
});
describe("runStateOf", () => {
it("maps every pg-boss state onto the card's four", () => {
expect(runStateOf("created")).toBe("queued");
expect(runStateOf("retry")).toBe("queued");
expect(runStateOf("active")).toBe("running");
expect(runStateOf("completed")).toBe("completed");
expect(runStateOf("failed")).toBe("failed");
expect(runStateOf("cancelled")).toBe("failed");
});
});
@@ -0,0 +1,110 @@
/**
* `POST /api/admin/maintenance/measurements` — queue the operator-triggered
* `VACUUM (ANALYZE)` / `REINDEX INDEX CONCURRENTLY` pass over `measurements`
* (v1.42). Cookie-only admin: `requireAdmin()` refuses every Bearer.
*
* Nothing runs in the request: the pass runs in the background worker
* (`measurement-maintenance.ts`), one statement at a time, and refuses to
* start while the compaction-tombstone purge is still working. At most one
* run is queued or running; a second press answers `enqueued: false`.
* Runbook: `docs/ops/measurement-maintenance.md`.
*
* `GET` reads where the newest run stands, for the admin card that starts it
* and follows it.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
MEASUREMENT_MAINTENANCE_QUEUE,
MEASUREMENT_MAINTENANCE_SEND_OPTIONS,
type MeasurementMaintenancePayload,
} from "@/lib/jobs/measurement-maintenance";
import { readMeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
import { annotate } from "@/lib/logging/context";
/** Two booleans; anything longer is not a request for this route. */
const MAX_BODY_BYTES = 1024;
const maintenanceRequestSchema = z
.object({
vacuum: z.boolean().default(true),
reindex: z.boolean().default(true),
})
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
// The body is optional: an empty one runs both steps.
const text = await request.text();
if (text.length > MAX_BODY_BYTES) {
return apiError("Request body too large", 413);
}
let body: unknown = {};
if (text.trim().length > 0) {
try {
body = JSON.parse(text);
} catch {
return apiError("Invalid JSON", 400);
}
}
const parsed = maintenanceRequestSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error);
const payload: MeasurementMaintenancePayload = {
vacuum: parsed.data.vacuum,
reindex: parsed.data.reindex,
};
if (!payload.vacuum && !payload.reindex) {
return apiError("Choose at least one step", 422);
}
annotate({
action: { name: "admin.maintenance.measurements.requested" },
meta: { vacuum: payload.vacuum, reindex: payload.reindex },
});
const boss = getGlobalBoss();
if (!boss) {
return apiError("Background worker is not available", 503);
}
const jobId = await boss.send(
MEASUREMENT_MAINTENANCE_QUEUE,
payload,
MEASUREMENT_MAINTENANCE_SEND_OPTIONS,
);
const enqueued = jobId !== null;
await auditLog("admin.maintenance.measurements.requested", {
userId: user.id,
ipAddress: getClientIp(request),
details: { ...payload, enqueued },
});
annotate({ meta: { maintenance_enqueued: enqueued } });
return apiSuccess({ enqueued }, 202);
});
export const GET = apiHandler(async () => {
await requireAdmin();
const status = await readMeasurementMaintenanceStatus();
annotate({
action: { name: "admin.maintenance.measurements.status" },
meta: {
available: status.available,
run_state: status.run?.state ?? null,
purge_pending: status.purgePending,
},
});
return apiSuccess(status);
});
@@ -4,6 +4,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import {
resolveUmamiCollectPath,
resolveUmamiSendUrls,
} from "@/lib/monitoring/umami";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
@@ -12,30 +16,6 @@ function redact(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[url]");
}
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const pathSegments = parsed.pathname.split("/").filter(Boolean);
const segments = [...pathSegments];
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}/api/send`,
`${origin}/api/send`,
`${origin}/umami/api/send`,
];
return Array.from(new Set(candidates));
} catch {
return [];
}
}
function resolveAppUrl(request: NextRequest): URL {
const configured =
envValue("APP_URL") ??
@@ -63,7 +43,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Umami script URL and website ID must be configured", 422);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
// The collect path is compiled into the operator's tracker build
// (`COLLECT_API_ENDPOINT`), so the test posts where the tracker would.
const collectPath = await resolveUmamiCollectPath(settings.umamiScriptUrl);
if (collectPath === null) {
return apiError("Umami script could not be fetched", 502);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl, collectPath);
if (targetUrls.length === 0) {
return apiError("Umami script URL is invalid", 422);
}
@@ -8,6 +8,7 @@ import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
@@ -182,7 +183,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const isOverdue = dayMatch && minutesPastEnd > 0;
const hasEvent = eventCount > schedulesProcessed;
if (isOverdue && !hasEvent && med.notificationsEnabled) {
// v1.42.2 — a medication whose doses are recorded automatically
// is never reminded, here or in the worker.
if (
isOverdue &&
!hasEvent &&
sendsMedicationReminders(med, { now, timeZone: userTz })
) {
const doseInfo = schedule.dose ?? med.dose;
const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`;
@@ -17,13 +17,24 @@ vi.mock("@/lib/db", () => ({
prisma: {
providerHealth: {
groupBy: vi.fn(),
// v1.42 — the paused rows.
findMany: vi.fn(async () => []),
},
coachUsage: {
aggregate: vi.fn(),
findMany: vi.fn(async () => []),
},
// v1.42 — the operator key's model check reads the admin settings.
appSettings: { findUnique: vi.fn(async () => null) },
},
}));
// v1.42 — the model check probes the operator endpoint's listing.
const probeModelListing = vi.fn();
vi.mock("@/lib/ai/model-availability", () => ({
probeModelListing: (...a: unknown[]) => probeModelListing(...a),
}));
vi.mock("@/lib/crypto", () => ({ decrypt: (v: string) => v }));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
@@ -48,7 +59,19 @@ import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
const groupBy = vi.mocked(prisma.providerHealth.groupBy);
const aggregate = vi.mocked(prisma.coachUsage.aggregate);
const usageRows = vi.mocked(prisma.coachUsage.findMany);
/** A spend row as the reader selects it. */
function usage(
dateKey: string,
totalTokens: number,
operatorTokens: number,
timezone: string | null = "UTC",
) {
return { dateKey, totalTokens, operatorTokens, user: { timezone } };
}
const utcToday = () => new Date().toISOString().slice(0, 10);
function group(
providerType: string,
@@ -80,9 +103,7 @@ describe("GET /api/admin/provider-health", () => {
vi.mocked(requireAdmin).mockResolvedValue({
user: { id: "admin1" },
} as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: 0, operatorTokens: 0 },
} as never);
usageRows.mockResolvedValue([] as never);
});
it("is admin-gated before it touches the ledger", async () => {
@@ -115,6 +136,9 @@ describe("GET /api/admin/provider-health", () => {
lastOkAt: "2026-08-27T07:00:00.000Z",
lastFailureAt: "2026-08-27T06:00:00.000Z",
lastFailureStatus: 503,
pausedUsers: 0,
pausedUntil: null,
modelListing: null,
},
]);
});
@@ -217,31 +241,44 @@ describe("GET /api/admin/provider-health", () => {
*/
it("reports the day's spend split by who paid for it", async () => {
groupBy.mockResolvedValue([] as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: 1_240_000, operatorTokens: 151_200 },
} as never);
usageRows.mockResolvedValue([
usage(utcToday(), 1_000_000, 100_000),
usage(utcToday(), 240_000, 51_200),
] as never);
const res = await GET();
const body = await res.json();
expect(body.data.spendToday).toEqual({
dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
totalTokens: 1_240_000,
operatorTokens: 151_200,
accounts: 2,
});
});
it("counts each account on its own today and nothing from another day", async () => {
groupBy.mockResolvedValue([] as never);
const yesterday = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
usageRows.mockResolvedValue([
usage(utcToday(), 500, 100),
// The same UTC account's yesterday: already closed, not today's spend.
usage(yesterday, 9_999, 9_999),
] as never);
const res = await GET();
const body = await res.json();
expect(body.data.spendToday).toEqual({
totalTokens: 500,
operatorTokens: 100,
accounts: 1,
});
// Today only, across every account — the operator's ceiling is per day.
expect(aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: { dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
}),
);
});
it("reports a day with no spend as zero, not null", async () => {
groupBy.mockResolvedValue([] as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: null, operatorTokens: null },
} as never);
usageRows.mockResolvedValue([] as never);
const res = await GET();
const body = await res.json();
@@ -257,3 +294,87 @@ describe("GET /api/admin/provider-health", () => {
expect(body.data.providers).toEqual([]);
});
});
describe("GET /api/admin/provider-health — pause and model check (v1.42)", () => {
const findMany = vi.mocked(prisma.providerHealth.findMany);
const settings = vi.mocked(prisma.appSettings.findUnique);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue({
user: { id: "admin" },
} as never);
usageRows.mockResolvedValue([] as never);
findMany.mockResolvedValue([] as never);
settings.mockResolvedValue(null as never);
});
it("counts the users a provider is paused for and when the last pause lifts", async () => {
groupBy.mockResolvedValue([
group("admin-openai", "hard_failed", 2, {
consecutiveFailures: 1766,
lastFailureAt: new Date("2026-10-08T04:31:00Z"),
lastStatus: 500,
}),
] as never);
findMany.mockResolvedValue([
{
providerType: "admin-openai",
nextRetryAt: new Date("2026-10-08T05:01:00Z"),
},
{
providerType: "admin-openai",
nextRetryAt: new Date("2026-10-09T04:31:00Z"),
},
] as never);
const body = await (await GET()).json();
expect(body.data.providers[0]).toMatchObject({
pausedUsers: 2,
pausedUntil: "2026-10-09T04:31:00.000Z",
});
// Only rows past the pause threshold, inside their window, are read.
expect(findMany.mock.calls[0][0]).toMatchObject({
where: {
lastResult: "hard_failed",
consecutiveFailures: { gte: 5 },
},
});
});
it("names a model the operator's endpoint no longer lists", async () => {
groupBy.mockResolvedValue([
group("admin-openai", "hard_failed", 1, {
consecutiveFailures: 9,
lastFailureAt: new Date("2026-10-08T04:31:00Z"),
lastStatus: 500,
}),
] as never);
settings.mockResolvedValue({
adminAiModel: "gpt-5.4-mini",
adminAiBaseUrl: "http://openai-oauth-proxy:10531/v1",
adminAiKeyEncrypted: "key",
} as never);
probeModelListing.mockResolvedValue("not_listed");
const body = await (await GET()).json();
expect(body.data.providers[0].modelListing).toEqual({
model: "gpt-5.4-mini",
listing: "not_listed",
});
expect(probeModelListing).toHaveBeenCalledWith(
expect.objectContaining({ operatorTrusted: true, model: "gpt-5.4-mini" }),
);
});
it("does not probe while the operator's key is healthy", async () => {
groupBy.mockResolvedValue([
group("admin-openai", "ok", 4, {
lastOkAt: new Date("2026-10-08T04:31:00Z"),
}),
] as never);
const body = await (await GET()).json();
expect(body.data.providers[0].modelListing).toBeNull();
expect(probeModelListing).not.toHaveBeenCalled();
});
});
+102 -11
View File
@@ -1,9 +1,17 @@
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { buildDateKey } from "@/lib/ai/coach/budget";
import { readInstanceSpendToday } from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress";
import { PROVIDER_PAUSE_THRESHOLD } from "@/lib/ai/provider-health-ledger";
import {
probeModelListing,
type ModelListing,
} from "@/lib/ai/model-availability";
import { decrypt } from "@/lib/crypto";
import { isAnthropicBaseUrl } from "@/lib/ai/provider";
import { isCanonicalOpenAIEndpoint } from "@/lib/ai/openai-capabilities";
export const dynamic = "force-dynamic";
@@ -42,6 +50,20 @@ interface ProviderHealthSummary {
* success clears the status along with the rest of the failure state.
*/
lastFailureStatus: number | null;
/**
* v1.42 — users whose chain has this provider PAUSED right now (a run of
* hard failures past the pause threshold, inside its window). The chain
* walks a paused provider last and tries it again once the window lifts.
*/
pausedUsers: number;
/** When the latest of those pauses lifts. Null when none is paused. */
pausedUntil: string | null;
/**
* v1.42 — for the operator's own key while it is failing: whether its
* endpoint still lists the configured model. `not_listed` is the fix the
* card names; null when there is nothing to check.
*/
modelListing: { model: string; listing: ModelListing } | null;
}
/**
@@ -54,9 +76,6 @@ export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.provider-health.get" } });
// The UTC day the ledger buckets by — the same key every reservation writes.
const dateKey = buildDateKey();
const groups = await prisma.providerHealth.groupBy({
// `lastStatus` joins the grouping key rather than an aggregate: the
// MAX of two HTTP statuses is not a status, it is arithmetic on a
@@ -71,6 +90,19 @@ export const GET = apiHandler(async () => {
},
});
// v1.42 — paused providers: a hard-failure run at or past the threshold,
// inside its window. Read as rows (one per user and type, two columns) so
// the count and the latest lift time are exact, not folded from a group.
const now = new Date();
const pausedRows = await prisma.providerHealth.findMany({
where: {
lastResult: "hard_failed",
consecutiveFailures: { gte: PROVIDER_PAUSE_THRESHOLD },
nextRetryAt: { gt: now },
},
select: { providerType: true, nextRetryAt: true },
});
const byType = new Map<string, ProviderHealthSummary>();
for (const g of groups) {
const row = byType.get(g.providerType) ?? {
@@ -81,6 +113,9 @@ export const GET = apiHandler(async () => {
lastOkAt: null,
lastFailureAt: null,
lastFailureStatus: null,
pausedUsers: 0,
pausedUntil: null,
modelListing: null,
};
row.tracked += g._count._all;
if (g.lastResult !== "ok") {
@@ -103,6 +138,21 @@ export const GET = apiHandler(async () => {
byType.set(g.providerType, row);
}
for (const paused of pausedRows) {
const row = byType.get(paused.providerType);
if (!row || paused.nextRetryAt === null) continue;
row.pausedUsers += 1;
const until = paused.nextRetryAt.toISOString();
if (!row.pausedUntil || until > row.pausedUntil) row.pausedUntil = until;
}
// The operator's own key, while it is failing: does its endpoint still list
// the configured model? A free GET, cached; only on this admin read.
const adminRow = byType.get("admin-openai");
if (adminRow && adminRow.failing > 0) {
adminRow.modelListing = await adminModelListing();
}
const providers = [...byType.values()].sort((a, b) => {
const ca = CENTRAL_TYPES.indexOf(a.providerType);
const cb = CENTRAL_TYPES.indexOf(b.providerType);
@@ -122,17 +172,58 @@ export const GET = apiHandler(async () => {
// alarming until you see that 151 200 of them were the instance's own key
// and the rest ran on the users' plans. The operator ceiling is enforced
// against the second figure, so both belong on screen.
const spend = await prisma.coachUsage.aggregate({
where: { dateKey },
_sum: { totalTokens: true, operatorTokens: true },
});
//
// "Today" is each account's own calendar day, the day its ceilings are
// enforced on (`readInstanceSpendToday`), not one date for the instance:
// the budget day is per account local day, so a single date key would
// count some accounts' yesterday or tomorrow and miss part of their today.
const spend = await readInstanceSpendToday();
return apiSuccess({
providers,
spendToday: {
dateKey,
totalTokens: spend._sum.totalTokens ?? 0,
operatorTokens: spend._sum.operatorTokens ?? 0,
totalTokens: spend.total,
operatorTokens: spend.operator,
accounts: spend.accounts,
},
});
});
/**
* The model check for the operator's key. Never fails the readout: a missing
* settings row, a key that does not decrypt, or a probe error is simply no
* answer.
*/
async function adminModelListing(): Promise<{
model: string;
listing: ModelListing;
} | null> {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminAiModel: true,
adminAiBaseUrl: true,
adminAiKeyEncrypted: true,
},
});
if (!settings?.adminAiKeyEncrypted) return null;
const baseUrl = settings.adminAiBaseUrl ?? "https://api.openai.com/v1";
// The same scope as the client's own check: an OpenAI-compatible
// endpoint the operator typed. `api.openai.com` names a missing model in
// its own error; Anthropic lists models on a different contract.
if (isCanonicalOpenAIEndpoint(baseUrl) || isAnthropicBaseUrl(baseUrl)) {
return null;
}
const model = settings.adminAiModel ?? "gpt-4o";
const listing = await probeModelListing({
baseUrl,
apiKey: decrypt(settings.adminAiKeyEncrypted),
model,
operatorTrusted: true,
});
return { model, listing };
} catch {
return null;
}
}
@@ -434,6 +434,41 @@ describe("PUT /api/admin/settings", () => {
expect(args?.update).toEqual({ defaultUserTimezone: null });
});
// Every shipped locale is a valid server default, not only de/en.
it("persists any shipped locale as the default language", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "fr",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
defaultUserTimezone: null,
} as never);
const res = await PUT(jsonReq({ defaultLocale: "fr" }));
expect(res.status).toBe(200);
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(args?.update).toEqual({ defaultLocale: "fr" });
});
it("returns 422 for a locale the app does not ship", async () => {
const res = await PUT(jsonReq({ defaultLocale: "xx" }));
expect(res.status).toBe(422);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("returns 422 for an invalid IANA zone in defaultUserTimezone", async () => {
const res = await PUT(jsonReq({ defaultUserTimezone: "Mars/Olympus" }));
expect(res.status).toBe(422);
@@ -46,6 +46,8 @@ const ALL_ON = {
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
aiReasoningEnabled: true,
aiReasoningMaxEffort: "high",
};
beforeEach(() => {
@@ -85,6 +87,34 @@ describe("GET /api/admin/settings/assistant-flags", () => {
expect(body.data.raw.assistantEnabled).toBe(true);
expect(body.data.resolved.coach).toBe(true);
});
it("publishes the reasoning controls, defaulted on and uncapped", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest("http://localhost/api/admin/settings/assistant-flags"),
);
const body = (await res.json()) as {
data: { reasoning: { enabled: boolean; maxEffort: string } };
};
expect(body.data.reasoning).toEqual({ enabled: true, maxEffort: "high" });
});
it("reads a stored cap and an off switch back", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
...ALL_ON,
aiReasoningEnabled: false,
aiReasoningMaxEffort: "low",
} as never);
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest("http://localhost/api/admin/settings/assistant-flags"),
);
const body = (await res.json()) as {
data: { reasoning: { enabled: boolean; maxEffort: string } };
};
expect(body.data.reasoning).toEqual({ enabled: false, maxEffort: "low" });
});
});
describe("PUT /api/admin/settings/assistant-flags", () => {
@@ -141,6 +171,46 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
expect(body.data.resolved.documentAi).toBe(false);
});
it("switches reasoning off and caps it, with an audit row", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
...ALL_ON,
aiReasoningEnabled: false,
aiReasoningMaxEffort: "medium",
} as never);
const res = await PUT(
putReq({ aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" }),
);
expect(res.status).toBe(200);
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0];
expect(upsert.update).toEqual({
aiReasoningEnabled: false,
aiReasoningMaxEffort: "medium",
});
const { auditLog } = await import("@/lib/auth/audit");
expect(vi.mocked(auditLog).mock.calls[0][1]).toMatchObject({
details: { aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" },
});
const body = (await res.json()) as {
data: { reasoning: { enabled: boolean; maxEffort: string } };
};
expect(body.data.reasoning).toEqual({
enabled: false,
maxEffort: "medium",
});
});
it.each(["off", "xhigh", "", 3])(
"refuses %s as the highest level",
async (value) => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
const res = await PUT(putReq({ aiReasoningMaxEffort: value }));
expect(res.status).toBe(422);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
},
);
it("rejects an empty body", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
const res = await PUT(putReq({}));
@@ -28,6 +28,14 @@
* so the admin UI can render the master vs sub-flag distinction
* visually.
*
* v1.41 — the same route carries the operator's two reasoning controls:
* `aiReasoningEnabled` (off wins over every person's Coach setting and every
* background job) and `aiReasoningMaxEffort` (`low` | `medium` | `high`, the
* highest level any call may take). Echoed as `reasoning: { enabled,
* maxEffort }`. They sit beside the assistant switches rather than in them:
* switching reasoning off stops no AI capability, it only stops the model
* thinking longer before it answers.
*
* `requireAdmin()` gates the route — non-admins get 403.
*/
import type { NextRequest } from "next/server";
@@ -45,6 +53,8 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { resolveAssistantFlags } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels";
import { parseReasoningMaxEffort } from "@/lib/ai/reasoning/resolve";
const assistantFlagsSchema = z
.object({
@@ -53,6 +63,8 @@ const assistantFlagsSchema = z
assistantBriefingEnabled: z.boolean().optional(),
assistantInsightStatusEnabled: z.boolean().optional(),
assistantDocumentAiEnabled: z.boolean().optional(),
aiReasoningEnabled: z.boolean().optional(),
aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(),
})
.strict();
@@ -62,8 +74,20 @@ type AssistantFlagsRow = {
assistantBriefingEnabled: boolean;
assistantInsightStatusEnabled: boolean;
assistantDocumentAiEnabled: boolean;
aiReasoningEnabled: boolean;
aiReasoningMaxEffort: string;
};
const ROW_SELECT = {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
aiReasoningEnabled: true,
aiReasoningMaxEffort: true,
} as const;
function buildResponseShape(row: AssistantFlagsRow) {
const resolved = resolveAssistantFlags({
enabled: row.assistantEnabled,
@@ -81,6 +105,10 @@ function buildResponseShape(row: AssistantFlagsRow) {
assistantDocumentAiEnabled: row.assistantDocumentAiEnabled,
},
resolved,
reasoning: {
enabled: row.aiReasoningEnabled,
maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort),
},
};
}
@@ -92,13 +120,7 @@ export const GET = apiHandler(async () => {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
},
select: ROW_SELECT,
});
const row: AssistantFlagsRow = {
@@ -108,6 +130,8 @@ export const GET = apiHandler(async () => {
assistantInsightStatusEnabled:
settings?.assistantInsightStatusEnabled ?? true,
assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true,
aiReasoningEnabled: settings?.aiReasoningEnabled ?? true,
aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high",
};
return apiSuccess(buildResponseShape(row));
@@ -128,14 +152,15 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
// Field by field from the parsed body; the schema is strict, so every key
// here is one the route owns.
const updates: Partial<AssistantFlagsRow> = {};
const auditDetails: Record<string, unknown> = {};
for (const [key, value] of Object.entries(parsed.data)) {
if (typeof value === "boolean") {
(updates as Record<string, boolean>)[key] = value;
auditDetails[key] = value;
if (value !== undefined) {
(updates as Record<string, boolean | string>)[key] = value;
}
}
const auditDetails: Record<string, unknown> = { ...updates };
if (Object.keys(updates).length === 0) {
return apiError("No valid fields", 422);
@@ -145,6 +170,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
where: { id: "singleton" },
update: updates,
create: { id: "singleton", ...updates },
select: ROW_SELECT,
});
await auditLog("admin.settings.assistant-flags.update", {
@@ -159,6 +185,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled,
aiReasoningEnabled: settings.aiReasoningEnabled,
aiReasoningMaxEffort: settings.aiReasoningMaxEffort,
};
return apiSuccess(buildResponseShape(row));
@@ -44,6 +44,17 @@ vi.mock("@/lib/jobs/worker-status", () => ({
})),
}));
const geoMock = vi.hoisted(() => ({
lookup: "online" as "offline" | "online" | "off",
chosen: false,
host: "ipwho.is",
}));
vi.mock("@/lib/geo", () => ({
geoLookupMode: () => geoMock.lookup,
geoProviderChosen: () => geoMock.chosen,
resolveGeoProviderHost: () => geoMock.host,
}));
vi.mock("@/lib/jobs/job-failures", () => ({
JOB_FAILURE_WINDOW_HOURS: 72,
readFailingQueues: vi.fn(),
@@ -208,4 +219,17 @@ describe("GET /api/admin/status (integration health summary)", () => {
],
});
});
it("carries the login-IP geo state the public version route no longer shows", async () => {
geoMock.lookup = "online";
geoMock.chosen = true;
geoMock.host = "ip-api.com";
const res = await GET();
const body = (await res.json()) as { data: { geo: unknown } };
expect(body.data.geo).toEqual({
lookup: "online",
providerChosen: true,
providerHost: "ip-api.com",
});
});
});
+12
View File
@@ -9,6 +9,11 @@ import {
readFailingQueues,
} from "@/lib/jobs/job-failures";
import { readFileSync } from "node:fs";
import {
geoLookupMode,
geoProviderChosen,
resolveGeoProviderHost,
} from "@/lib/geo";
// Build timestamp (set at build time via next.config)
const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString();
@@ -109,5 +114,12 @@ export const GET = apiHandler(async () => {
: null,
webPush: webPushConfigured ? { configured: true } : null,
},
// How login IPs are placed on this host. Admin-only: the public
// `/api/version` keeps just `offlineGeoEnabled` and `geoProviderHost`.
geo: {
lookup: geoLookupMode(),
providerChosen: geoProviderChosen(),
providerHost: resolveGeoProviderHost(),
},
});
});
+4 -4
View File
@@ -145,7 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// `admin-key` is the operator's own credential and draws the operator
// ceiling; an override names the caller's own key, and every other resolved
// type is the user's own egress, so those draw the user-plan ceiling.
const dateKey = buildDateKey();
const dateKey = await buildDateKey(user.id);
const reservation = await reserveBudget(
user.id,
AI_TEST_MAX_TOKENS,
@@ -276,13 +276,13 @@ function classifyTestFailure(
return {
reasonCode: "credentials",
reason:
"Provider rejected the credentials — re-authenticate in AI settings.",
"Provider rejected the credentials. Re-authenticate in AI settings.",
};
}
if (status === 429) {
return {
reasonCode: "rate_limited",
reason: "Provider rate-limited the request — try again shortly.",
reason: "Provider rate-limited the request. Try again shortly.",
};
}
if (status >= 500) {
@@ -298,7 +298,7 @@ function classifyTestFailure(
if (status >= 400 && status < 500) {
return {
reasonCode: "bad_request",
reason: `The provider rejected the request (HTTP ${status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.`,
reason: `The provider rejected the request (HTTP ${status}). The endpoint answered, so this is a request-shape or model-name problem, not connectivity.`,
};
}
return {
@@ -58,10 +58,13 @@ vi.mock("@/lib/profile/health-facts", () => ({
import { NextRequest } from "next/server";
import { POST } from "../route";
import { requestFingerprint } from "@/lib/idempotency";
/** The pending sentinel `findCached` recognises as "another call is running". */
const PENDING_STATUS = 0;
const BODY = JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" });
function request(): NextRequest {
return new NextRequest("http://localhost/api/anamnesis/facts", {
method: "POST",
@@ -69,7 +72,7 @@ function request(): NextRequest {
"content-type": "application/json",
"idempotency-key": "key-in-flight",
},
body: JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" }),
body: BODY,
});
}
@@ -99,6 +102,12 @@ describe("idempotency 409 envelope, over the real route", () => {
it("serves `error` as a string when a request is already in flight", async () => {
mocks.findUnique.mockResolvedValue({
id: "idem-1",
// The same request in flight: a different body would run instead.
requestFingerprint: requestFingerprint(
"POST",
"/api/anamnesis/facts",
new TextEncoder().encode(BODY),
),
responseStatus: PENDING_STATUS,
responseBody: "",
expiresAt: new Date(Date.now() + 60_000),
@@ -0,0 +1,266 @@
/**
* v1.42 (#959) — the anonymous claim and its preview, at the route seam.
*
* The transaction itself is integration-tested
* (`tests/integration/managed-profile-handover.test.ts`); here the order of
* the refusals is pinned, because each one is a security property:
*
* - single sign-on only refuses before anything else;
* - a live session is refused BEFORE the rate limit (a state, not an
* attempt), and before any token is read;
* - the rate limit runs before the body is parsed;
* - an unusable token answers the same 404 on both routes, and the claim
* checks the link BEFORE probing whether a username or email is taken, so
* the route is not a name oracle for somebody without a live link;
* - nothing is hashed for a token that is refused.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn(),
getSession: vi.fn(),
}));
vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/auth/password", () => ({
hashPassword: vi.fn(),
checkPasswordStrength: vi.fn(),
}));
vi.mock("@/lib/password-breach-check", () => ({
checkPasswordBreachIfEnabled: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn().mockResolvedValue("en"),
}));
vi.mock("@/lib/managed-profiles/handover", async () => {
const actual = await vi.importActual<
typeof import("@/lib/managed-profiles/handover")
>("@/lib/managed-profiles/handover");
return {
HandoverError: actual.HandoverError,
previewHandover: vi.fn(),
claimManagedProfile: vi.fn(),
};
});
vi.mock("@/lib/managed-profiles/handover-notify", () => ({
notifyGuardiansOfHandover: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST as claim } from "../route";
import { POST as preview } from "../preview/route";
import { prisma } from "@/lib/db";
import { createSession, getSession } from "@/lib/auth/session";
import { isOidcOnly } from "@/lib/auth/oidc";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import {
claimManagedProfile,
HandoverError,
previewHandover,
} from "@/lib/managed-profiles/handover";
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
const TOKEN = `hlp_${"a".repeat(64)}`;
const BODY = {
token: TOKEN,
username: "alex",
email: "alex@example.test",
password: "a long enough passphrase",
};
function post(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const PREVIEW = {
displayName: "Alex",
expiresAt: new Date("2030-01-01T00:00:00Z"),
guardians: [{ grantId: "g1", displayName: "Sam", proposal: "read" as const }],
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(isOidcOnly).mockReturnValue(false);
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "203.0.113.7",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
vi.mocked(checkPasswordStrength).mockReturnValue({
isAcceptable: true,
feedback: [],
} as never);
vi.mocked(checkPasswordBreachIfEnabled).mockResolvedValue(null as never);
vi.mocked(hashPassword).mockResolvedValue("argon2-hash");
vi.mocked(previewHandover).mockResolvedValue(PREVIEW);
vi.mocked(claimManagedProfile).mockResolvedValue({
profileId: "p1",
username: "alex",
displayName: "Alex",
guardians: [{ guardianId: "u-sam", access: "read" }],
});
});
async function errorCode(res: Response): Promise<unknown> {
const body = (await res.json()) as { meta?: { errorCode?: string } };
return body.meta?.errorCode;
}
describe.each([
["claim", claim, "/api/auth/claim", BODY],
["preview", preview, "/api/auth/claim/preview", { token: TOKEN }],
] as const)(
"POST %s — refusals that read no token",
(_n, route, path, body) => {
it("refuses on a single-sign-on-only instance before anything else", async () => {
vi.mocked(isOidcOnly).mockReturnValue(true);
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
const res = await route(post(path, body));
expect(res.status).toBe(403);
expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported");
expect(previewHandover).not.toHaveBeenCalled();
});
it("refuses over a live session before spending the rate limit", async () => {
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
const res = await route(post(path, body));
expect(res.status).toBe(409);
expect(await errorCode(res)).toBe("auth.already_authenticated");
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
expect(previewHandover).not.toHaveBeenCalled();
});
it("answers 429 from the per-address bucket before parsing the body", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
ip: "203.0.113.7",
} as never);
const res = await route(post(path, body));
expect(res.status).toBe(429);
expect(previewHandover).not.toHaveBeenCalled();
});
it("answers the same 404 for an unusable link", async () => {
vi.mocked(previewHandover).mockResolvedValue(null);
const res = await route(post(path, body));
expect(res.status).toBe(404);
expect(await errorCode(res)).toBe("profile_claim.invalid");
});
},
);
describe("POST /api/auth/claim", () => {
it("checks the link before the username and email probe", async () => {
vi.mocked(previewHandover).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
const res = await claim(post("/api/auth/claim", BODY));
// A taken name would be a 409; the link is checked first, so 404.
expect(res.status).toBe(404);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(hashPassword).not.toHaveBeenCalled();
});
it("answers 409 profile_claim.taken for a name in use, before hashing", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
id: "x",
} as never);
const res = await claim(post("/api/auth/claim", BODY));
expect(res.status).toBe(409);
expect(await errorCode(res)).toBe("profile_claim.taken");
expect(hashPassword).not.toHaveBeenCalled();
expect(claimManagedProfile).not.toHaveBeenCalled();
});
it("refuses a username with the managed-profile prefix as 422", async () => {
const res = await claim(
post("/api/auth/claim", { ...BODY, username: "managed-alex" }),
);
expect(res.status).toBe(422);
expect(previewHandover).not.toHaveBeenCalled();
});
it("refuses an unknown field (strict body)", async () => {
const res = await claim(
post("/api/auth/claim", { ...BODY, userId: "someone-else" }),
);
expect(res.status).toBe(422);
});
it("maps a race lost inside the transaction to the same 404", async () => {
vi.mocked(claimManagedProfile).mockRejectedValue(
new HandoverError("invalid"),
);
const res = await claim(post("/api/auth/claim", BODY));
expect(res.status).toBe(404);
expect(createSession).not.toHaveBeenCalled();
});
it("maps a unique-constraint race to 409 taken", async () => {
vi.mocked(claimManagedProfile).mockRejectedValue(
new HandoverError("taken"),
);
const res = await claim(post("/api/auth/claim", BODY));
expect(res.status).toBe(409);
expect(await errorCode(res)).toBe("profile_claim.taken");
});
it("signs the new owner in with onboarding owed and tells the guardians", async () => {
const res = await claim(post("/api/auth/claim", BODY));
expect(res.status).toBe(201);
expect(claimManagedProfile).toHaveBeenCalledWith(
expect.objectContaining({
rawToken: TOKEN,
username: "alex",
email: "alex@example.test",
passwordHash: "argon2-hash",
}),
);
expect(createSession).toHaveBeenCalledWith("p1", true, "203.0.113.7", null);
expect(notifyGuardiansOfHandover).toHaveBeenCalledWith("claimed", "Alex", [
{ guardianId: "u-sam", access: "read" },
]);
const body = (await res.json()) as { data: unknown };
expect(body.data).toEqual({ userId: "p1", username: "alex" });
});
});
describe("POST /api/auth/claim/preview", () => {
it("returns the minimal preview and nothing else", async () => {
const res = await preview(
post("/api/auth/claim/preview", { token: TOKEN }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(Object.keys(body.data).sort()).toEqual([
"displayName",
"expiresAt",
"guardians",
]);
expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z");
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* `POST /api/auth/claim/preview` — what a handover link would hand over: the
* profile's display name, the link's expiry and each Guardian's proposed
* access (v1.42, #959). No health data.
*
* Anonymous by design: the person opening the link has no credentials yet.
* The one-time `hlp_` token travels in the BODY, never in this request's URL,
* so it never reaches an access log or a wide event's `http.path`. Every
* unusable link — malformed, unknown, expired, used, withdrawn, a profile no
* longer managed, a creator who lost access — answers the same 404.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
import { previewHandover } from "@/lib/managed-profiles/handover";
import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover";
export const POST = apiHandler(async (request: NextRequest) => {
// Refuses to preview over a live session — see `claimGate`.
const gate = await claimGate(
request,
"preview",
(await getSession()) !== null,
);
if (gate.refusal) return gate.refusal;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimPreviewSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const preview = await previewHandover(parsed.data.token);
annotate({
action: { name: "profile_claim.preview" },
meta: { found: preview !== null },
});
if (!preview) return claimInvalid();
return apiSuccess({
displayName: preview.displayName,
expiresAt: preview.expiresAt.toISOString(),
guardians: preview.guardians,
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* `POST /api/auth/claim` — claim a managed profile through its handover link
* (v1.42, #959). The person the profile describes sets a username, an email
* and a password, and the profile becomes their own account. No data moves.
*
* Anonymous by design: the claimant has no credentials yet, and the one-time
* `hlp_` token in the BODY is what authorises the request. The order of the
* work is the registration route's, for the same reasons:
*
* 1. refusals that read no token (`claimGate`: SSO-only, live session, rate
* limit);
* 2. the body, strictly parsed, and the link checked;
* 3. the username and email collision probe, before any expensive work;
* 4. password strength, the breach corpus, the Argon2id hash — outside the
* lock, because none of it depends on the profile;
* 5. the claim itself, one transaction under the managed-profile lock
* (`claimManagedProfile`), where the token is consumed;
* 6. after the commit: the session, the device record, and a notification
* to each former Guardian on their own channels.
*
* A closed registration does not block a claim: no account is created, the
* link is the door.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
import { createSession, getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { annotate } from "@/lib/logging/context";
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
import {
claimManagedProfile,
HandoverError,
previewHandover,
} from "@/lib/managed-profiles/handover";
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { claimSchema } from "@/lib/validations/managed-profile-handover";
const taken = () =>
apiError("Username or email already taken", 409, {
errorCode: "profile_claim.taken",
});
export const POST = apiHandler(async (request: NextRequest) => {
// Refuses to claim over a live session — see `claimGate`.
const gate = await claimGate(request, "claim", (await getSession()) !== null);
if (gate.refusal) return gate.refusal;
const ip = gate.ip ?? "unknown";
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const { token, username, email, password } = parsed.data;
// The link first. Without this the collision probe below would answer 409
// to anybody, token or not, and the claim route would be a username and
// email oracle that registration being closed does not close. With it, only
// the holder of a live link learns that a name is taken. The claim re-checks
// everything under the lock; this read decides nothing on its own.
if (!(await previewHandover(token))) return claimInvalid();
// Before the strength check, the breach lookup and the hash, exactly as the
// registration route orders it (its comment carries the timing argument).
// The profile's own row never collides here: its username is the generated
// `managed-…` one, which no chosen name may start with, and it has no email.
const [existingEmail, existingUsername] = await Promise.all([
prisma.user.findUnique({ where: { email }, select: { id: true } }),
prisma.user.findUnique({ where: { username }, select: { id: true } }),
]);
if (existingEmail || existingUsername) return taken();
const locale = await resolveServerLocale({ request });
const strength = checkPasswordStrength(password, [username, email], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const breach = await checkPasswordBreachIfEnabled(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
422,
);
}
const passwordHash = await hashPassword(password);
let claimed;
try {
claimed = await claimManagedProfile({
rawToken: token,
username,
email,
passwordHash,
ipAddress: ip,
});
} catch (error) {
if (error instanceof HandoverError) {
annotate({
action: { name: "profile_claim.refused" },
meta: { reason: error.code },
});
return error.code === "taken" ? taken() : claimInvalid();
}
throw error;
}
// The new owner starts with onboarding owed: the claim reset it, because
// the disclaimer and the setup were a Guardian's answers, not theirs.
const ua = request.headers.get("user-agent");
await createSession(claimed.profileId, true, ip, ua);
void recordSignInDevice({
userId: claimed.profileId,
ip,
userAgent: ua,
alertOnNew: false,
});
await auditLog("auth.claim", {
userId: claimed.profileId,
actorUserId: null,
ipAddress: ip,
details: { method: "password" },
});
notifyGuardiansOfHandover("claimed", claimed.displayName, claimed.guardians);
annotate({
action: { name: "profile_claim.complete" },
meta: { guardians: claimed.guardians.length },
});
return apiSuccess(
{ userId: claimed.profileId, username: claimed.username },
201,
);
});
+2 -2
View File
@@ -47,7 +47,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
userCode = decoded.userCode;
} catch {
cookieStore.delete("codex_device");
return apiError("Invalid device-auth state — restart the flow", 400);
return apiError("Invalid device-auth state. Restart the flow", 400);
}
let result;
@@ -56,7 +56,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { codex_device_poll_error: message } });
return apiError("Codex device-poll failed — please restart the flow", 502);
return apiError("Codex device-poll failed. Please restart the flow", 502);
}
if (result.status === "pending") {
@@ -172,3 +172,50 @@ describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => {
it("stores the chosen level and names it on the wide event", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
} as never);
const { annotate } = await import("@/lib/logging/context");
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ reasoning: "high" }),
);
expect(res.status).toBe(200);
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { coachPrefsJson: { reasoning?: string } };
};
expect(call.data.coachPrefsJson.reasoning).toBe("high");
const env = (await res.json()) as { data: { reasoning?: string } };
expect(env.data.reasoning).toBe("high");
const meta = vi
.mocked(annotate)
.mock.calls.map((c) => c[0])
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
expect(meta).toMatchObject({ reasoning: "high" });
});
it("reads an absent level as the default on the wide event", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
} as never);
const { annotate } = await import("@/lib/logging/context");
await (PUT as (r: Request) => Promise<Response>)(mkPut({ tone: "warm" }));
const meta = vi
.mocked(annotate)
.mock.calls.map((c) => c[0])
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
expect(meta).toMatchObject({ reasoning: "medium" });
});
it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => {
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ reasoning: value }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+5
View File
@@ -31,6 +31,7 @@ import {
} from "@/lib/optimistic-lock";
import {
coachPrefsSchema,
coachReasoningLevel,
parseCoachPrefs,
} from "@/lib/validations/coach-prefs";
@@ -130,6 +131,10 @@ export const PUT = apiHandler(async (req: Request) => {
// v1.7.0 — `null` when the caller left `dataClusters` absent
// (legacy defaults stay in force); otherwise the selected count.
clusterCount: parsed.data.dataClusters?.length ?? null,
// v1.41 — the stored thinking depth, absent read as the default. The
// operator's switch and cap apply on top of it at turn time; the
// resolved value is `coachReasoning` on GET /api/auth/me.
reasoning: coachReasoningLevel(parsed.data),
},
});
return apiSuccess({ ...parsed.data, updatedAt: guarded.updatedAt });
+4
View File
@@ -28,6 +28,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { toCycleProfileDTO } from "@/lib/cycle/dto";
import { cyclePrefsSchema } from "@/lib/validations/cycle";
@@ -118,6 +119,9 @@ export const PATCH = apiHandler(async (req: Request) => {
});
const resolved = isCycleEnabled(user.gender, updated);
// The typical lengths, the prediction switch and tracking itself all feed
// the dashboard's cycle line, which is cached in the digest extras cell.
invalidateUserHealthContext(user.id);
await auditLog("user.cycle-prefs.update", {
userId: user.id,
@@ -88,7 +88,7 @@ const healthScoreConfigPatchSchema = z
*/
const BREADTH_REFUSAL = {
no_pillars_selected:
"A health score needs at least one area of health to score. Keep one selected — the score will say which areas it is built from.",
"A health score needs at least one area of health to score. Keep one selected. The score will say which areas it is built from.",
} as const;
export const GET = apiHandler(async () => {
@@ -104,12 +104,12 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -141,12 +141,12 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -180,12 +180,12 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
});
@@ -230,12 +230,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
// v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
@@ -259,12 +259,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
@@ -289,12 +289,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
next: {
medication: {
@@ -311,12 +311,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
changed: ["medication"],
}),
@@ -395,12 +395,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
@@ -435,12 +435,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
// v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
@@ -464,12 +464,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
@@ -510,17 +510,51 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
});
it("persists content.detail and keeps the siblings", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { mood: { reminderHour: 7 } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ content: { detail: "detailed" } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { content: { detail: string }; mood: { reminderHour: number } };
};
expect(env.data.content).toEqual({ detail: "detailed" });
expect(env.data.mood).toEqual({ reminderHour: 7 });
const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { notificationPrefs: { content: { detail: string } } };
};
expect(written.data.notificationPrefs.content).toEqual({
detail: "detailed",
});
});
it("rejects an unknown content.detail with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ content: { detail: "everything" } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("v1.7.0 — rejects a mood.reminderHour outside 0..23 with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+37 -1
View File
@@ -72,9 +72,19 @@ import {
} from "@/lib/sharing/module-disclosure";
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { storedGlucoseUnit } from "@/lib/glucose";
export const dynamic = "force-dynamic";
/**
* Capability flags for clients that are not shipped with the server. Constant
* by design: a feature the server supports is supported for every account,
* and a per-account answer belongs in `modules` or `ai`, not here.
*/
const SERVER_FEATURES = { trackIntake: true } as const;
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
const { user } = auth;
@@ -143,6 +153,7 @@ export const GET = apiHandler(async () => {
moduleAvailability,
onboardingRow,
ai,
coachReasoning,
] = await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
@@ -206,6 +217,16 @@ export const GET = apiHandler(async () => {
sections,
recordKind,
}),
// v1.41 — the Coach's thinking depth, resolved: the caller's own
// preference (Coach preferences belong to the person, like every other
// display preference here) against the operator's switch and cap and the
// record's provider. The provider read is the memoised presence probe the
// `ai` block above already ran.
loadCoachReasoningState({
prefs: parseCoachPrefs(user.coachPrefsJson),
recordId,
authority: aiAuthority,
}),
]);
// The masking step, and the one place the record scoping is narrowed rather
@@ -318,7 +339,8 @@ export const GET = apiHandler(async () => {
avatarUrl: user.avatarUpdatedAt
? buildAvatarUrl(user.id, user.avatarUpdatedAt)
: null,
glucoseUnit: user.glucoseUnit ?? null,
// The stored choice, published as the closed pair the contract names.
glucoseUnit: storedGlucoseUnit(user.glucoseUnit),
// v1.7.0 — global metric/imperial display preference. Canonical
// storage stays SI; this only drives the display-time transform
// branch. Null defaults to "metric" on the client.
@@ -397,6 +419,14 @@ export const GET = apiHandler(async () => {
// the RECORD and masked to the grant, like `modules`. Clients render from
// it and never recompute it; data never depends on it.
ai,
// v1.41 — the Coach's thinking depth, resolved on the server so the web
// settings and the native client show the same answer: `level` is what a
// turn runs at now, `preference` what the person picked, `maxLevel` the
// highest option the settings may offer (`off` when the operator switched
// reasoning off), `available` whether reasoning can run at all,
// `offIsReal` whether the lowest option is a real off or "Minimal", and
// `source` which layer decided. Clients never recompute it.
coachReasoning,
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
// `active` is the record this session is inside (null when it is in its
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
@@ -421,5 +451,11 @@ export const GET = apiHandler(async () => {
// behind it have one reader by design, and a route that projected them
// itself would be a second statement of what a record context is.
recordSession: recordSessionForPayload(auth),
// v1.42 — what this server understands, for a client released on its own
// schedule (iOS #116, item 18). `trackIntake: true` says medications take
// the record-only switch, so an add form can offer it before the account
// has a medication to learn it from. A flag only ever appears here once
// the server supports it, and stays `true`; absence means an older server.
features: SERVER_FEATURES,
});
});
@@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/integrations/sources-in-use", () => ({
getSourcesInUse: vi.fn().mockResolvedValue(["WITHINGS", "MANUAL"]),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -73,6 +77,17 @@ describe("GET /api/auth/me/source-priority", () => {
expect(env.data).toHaveProperty("metricPriority");
expect(env.data).toHaveProperty("deviceTypePriority");
});
it("carries the account's sources in use beside the ladders", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
const env = (await res.json()) as { data: { inUse: string[] } };
expect(env.data.inUse).toEqual(["WITHINGS", "MANUAL"]);
});
});
describe("PUT /api/auth/me/source-priority", () => {
+17 -5
View File
@@ -30,6 +30,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserProfile } from "@/lib/cache/invalidate";
import { getSourcesInUse } from "@/lib/integrations/sources-in-use";
import {
parseSourcePriority,
sourcePrioritySchema,
@@ -41,11 +42,19 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.source-priority.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
const [row, inUse] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
}),
getSourcesInUse(user.id),
]);
// `inUse` is read-only context for the editor: which ranked sources this
// account has connected or holds data from. It never feeds the ladder.
return apiSuccess({
...parseSourcePriority(row?.sourcePriorityJson),
inUse,
});
return apiSuccess(parseSourcePriority(row?.sourcePriorityJson));
});
export const PUT = apiHandler(async (req: Request) => {
@@ -118,5 +127,8 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.source-priority.put" },
meta: { keys: Object.keys(parsed.data).length },
});
return apiSuccess(parseSourcePriority(parsed.data));
return apiSuccess({
...parseSourcePriority(parsed.data),
inUse: await getSourcesInUse(user.id),
});
});
@@ -0,0 +1,164 @@
/**
* A passkey sign-in that cannot be checked answers with its own status and
* `meta.errorCode`, never a 500. The real `verifyAuthentication` runs here
* against a mocked database, so the test pins the whole path from the
* library's refusal to the wire: an expired or already-used challenge, a
* credential this server does not know, a body that is not an assertion, and
* an assertion the WebAuthn library refuses.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
passkey: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
vi.mock("@simplewebauthn/server", async (importOriginal) => ({
...(await importOriginal<typeof import("@simplewebauthn/server")>()),
verifyAuthenticationResponse: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
const ASSERTION = {
id: "cred-1",
rawId: "cred-1",
type: "public-key",
response: {
clientDataJSON: "e30",
authenticatorData: "AA",
signature: "AA",
},
};
function request(credential: unknown = ASSERTION): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ challengeId: "ch-1", credential }),
});
}
async function refusal(res: Response) {
const body = (await res.json()) as {
data: null;
meta?: { errorCode?: string };
};
return { status: res.status, code: body.meta?.errorCode, data: body.data };
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ challenge: "c-1", user_id: null },
] as never);
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
id: "pk-1",
userId: "user-1",
credentialId: "cred-1",
credentialPublicKey: new Uint8Array([1]),
counter: BigInt(0),
transports: [],
} as never);
});
describe("POST /api/auth/passkey/login-verify — named refusals", () => {
it("answers an expired or already-used challenge with 401 passkey.challenge.expired", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
expect(await refusal(await POST(request()))).toEqual({
status: 401,
code: "passkey.challenge.expired",
data: null,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.login.failed",
expect.objectContaining({
details: { reason: "passkey_challenge_expired" },
}),
);
});
it("answers a challenge begun for another account the same way", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ challenge: "c-1", user_id: "someone-else" },
] as never);
const out = await refusal(await POST(request()));
expect(out.status).toBe(401);
expect(out.code).toBe("passkey.challenge.expired");
});
it("answers a passkey that is not registered here with 404 passkey.unknown", async () => {
vi.mocked(prisma.passkey.findUnique).mockResolvedValue(null as never);
expect(await refusal(await POST(request()))).toEqual({
status: 404,
code: "passkey.unknown",
data: null,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.login.failed",
expect.objectContaining({ details: { reason: "passkey_unknown" } }),
);
});
it("answers a body that is not an assertion with 422 passkey.response.invalid", async () => {
const out = await refusal(await POST(request({ id: "cred-1" })));
expect(out.status).toBe(422);
expect(out.code).toBe("passkey.response.invalid");
});
it("answers an assertion the library refuses with 401 passkey.verification.failed", async () => {
vi.mocked(verifyAuthenticationResponse).mockRejectedValue(
new Error("Unexpected authentication response origin"),
);
const out = await refusal(await POST(request()));
expect(out.status).toBe(401);
expect(out.code).toBe("passkey.verification.failed");
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("still lets a real fault surface as one", async () => {
vi.mocked(prisma.passkey.findUnique).mockRejectedValue(
new Error("connection reset"),
);
const res = await POST(request());
expect(res.status).toBe(500);
});
});
+69 -6
View File
@@ -1,4 +1,8 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import {
PasskeyAuthError,
verifyAuthentication,
type PasskeyAuthErrorKind,
} from "@/lib/auth/passkey";
import { createSession } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { recordSignInDevice } from "@/lib/auth/login-alert";
@@ -20,6 +24,43 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { isOidcOnly } from "@/lib/auth/oidc";
import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
const PASSKEY_VERIFICATION_FAILED_CODE = "passkey.verification.failed";
/**
* How each passkey refusal answers. The challenge and verification cases are
* 401 (the sign-in did not prove anything), the unknown credential is 404 (the
* passkey is not registered on this server), and a malformed body is 422.
*/
const PASSKEY_REFUSALS: Record<
PasskeyAuthErrorKind,
{ status: number; errorCode: string; message: string; auditReason: string }
> = {
challenge_expired: {
status: 401,
errorCode: "passkey.challenge.expired",
message: "The sign-in took too long or was already used. Try again.",
auditReason: "passkey_challenge_expired",
},
unknown_passkey: {
status: 404,
errorCode: "passkey.unknown",
message: "This passkey is not registered here.",
auditReason: "passkey_unknown",
},
malformed: {
status: 422,
errorCode: "passkey.response.invalid",
message: "The passkey response could not be read.",
auditReason: "passkey_response_invalid",
},
verification_failed: {
status: 401,
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
message: "Passkey verification failed",
auditReason: "passkey_verification_failed",
},
};
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block passkey login too, not just password login — a
// credential minted before SSO was mandated must not stay a live bypass.
@@ -55,17 +96,39 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("challengeId and credential required", 422);
}
const { verification, passkey } = await verifyAuthentication(
challengeId,
credential,
);
let outcome: Awaited<ReturnType<typeof verifyAuthentication>>;
try {
outcome = await verifyAuthentication(challengeId, credential);
} catch (err) {
// A known client-side case — an expired challenge, a passkey that is no
// longer registered here, a malformed or refused assertion — answers
// with its own status and code (iOS #116, item 20) so the app can tell
// the person what to do instead of reporting a server error. Anything
// else is a real fault and keeps propagating.
if (!(err instanceof PasskeyAuthError)) throw err;
const refusal = PASSKEY_REFUSALS[err.kind];
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: refusal.auditReason },
});
annotate({
action: { name: "auth.login.passkey.refused" },
meta: { reason: err.kind },
});
return apiError(refusal.message, refusal.status, {
errorCode: refusal.errorCode,
});
}
const { verification, passkey } = outcome;
if (!verification.verified) {
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: "passkey_verification_failed" },
});
return apiError("Passkey verification failed", 401);
return apiError("Passkey verification failed", 401, {
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
});
}
const user = await prisma.user.findUnique({
+1 -1
View File
@@ -135,7 +135,7 @@ export const DELETE = apiHandler(
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Cannot delete — at least one authentication method must remain",
"Cannot delete. At least one authentication method must remain",
400,
);
}
+2 -2
View File
@@ -96,14 +96,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
// the token and re-authenticates.
if (result.reason === "already_used") {
return apiError(
"Refresh token reuse detected — please log in again.",
"Refresh token reuse detected. Please log in again.",
401,
{ errorCode: "auth.refresh.reuse" },
);
}
if (result.reason === "revoked") {
return apiError(
"Refresh token has been revoked — please log in again.",
"Refresh token has been revoked. Please log in again.",
401,
{ errorCode: "auth.refresh.revoked" },
);
+1 -1
View File
@@ -58,7 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (existingSession) {
annotate({ action: { name: "auth.register.refused_authenticated" } });
return apiError(
"Already signed in — sign out first to use an invitation",
"Already signed in. Sign out first to use an invitation",
409,
{ errorCode: "auth.already_authenticated" },
);
Loaded 100 of 2097 files, more files were not shown because too many files have changed in this diff. Show more