Merge branch 'release/v1.45.0' into feat/v145-runtime

This commit is contained in:
Marc-André Bombeck committed 2026-10-22 09:41:54 +02:00
commit 6dfb7eada6
45 files changed
+3547 -1834

No files matched your search

+6
View File
@@ -28,6 +28,12 @@ playwright/.cache
playwright.config.ts
vitest.config.mts
vitest.integration.config.mts
vitest.mutation.config.mts
# The unit setup imports tests/ (excluded above); the image never runs it and
# the build's type check must not see a half-present test harness.
vitest.setup.ts
stryker.config.mjs
stryker.floors.json
tests
tests/
e2e
+1 -1
View File
@@ -271,7 +271,7 @@ RUN mkdir -p /opt/prisma-cli && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@18.0.6 && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.8 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
+1303 -703
View File
File diff suppressed because it is too large. Load diff
+9 -5
View File
@@ -89,10 +89,12 @@ Every call is also a contract check. The status must be one the registry
(`src/lib/openapi/routes/`) declares for that path and method, a JSON body
must parse with the registered schema, and a key the schema does not declare
fails the call: a client generated from the contract cannot see that field, so
nobody promised to keep it. The one generic answer accepted without its own
entry is the scope gate's 403 `auth.scope.insufficient`, which the shared 401
documents for every authenticated operation. A test that calls an unregistered
route on purpose passes `contract: { skip: "<reason>" }`.
nobody promised to keep it. There is no generic answer the kit accepts on an
operation's behalf: the scope gate's 403 `auth.scope.insufficient` is declared
on every operation that passes the gate (`withScopeRefusal` in
`src/lib/openapi/routes/shared.ts`), so it is checked like any other status. A
test that calls an unregistered route on purpose passes
`contract: { skip: "<reason>" }`.
`tests/integration/routes/kit-contract.test.ts` proves the check can fail.
Assert what a person or a client would notice: the stored row, the response,
@@ -119,7 +121,9 @@ Each Vitest worker clones it on its first file (`CREATE DATABASE … TEMPLATE`,
see `tests/integration/worker-database.ts`), so files run in parallel without
seeing each other's rows, advisory locks or queue schema. The worker count
follows the host (half the cores, at most eight) unless
`HEALTHLOG_INTEGRATION_WORKERS` pins it; CI uses three.
`HEALTHLOG_INTEGRATION_WORKERS` pins it; CI uses three. To reproduce a CI runner's
contention on a larger machine, cap the database container with
`HEALTHLOG_INTEGRATION_PG_CPUS=2`.
`truncateAllTables` empties every table the migrations created and puts back
the rows they seed (the mood-tag and symptom catalogues), so a test starts
+20 -20
View File
@@ -53,8 +53,8 @@
"mcp:stdio": "npx tsx src/cli/mcp-stdio.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1150.0",
"@aws-sdk/lib-storage": "3.1150.0",
"@aws-sdk/client-s3": "^3.1152.0",
"@aws-sdk/lib-storage": "3.1152.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -62,42 +62,42 @@
"@napi-rs/canvas": "1.0.11",
"@node-rs/argon2": "^2.2.2",
"@parse/node-apn": "^8.1.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.3",
"@tanstack/react-query": "5.104.2",
"@tanstack/react-virtual": "^3.14.17",
"@tanstack/react-query": "5.104.3",
"@tanstack/react-virtual": "^3.14.21",
"@types/sax": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dotenv": "^18.0.7",
"dotenv": "^18.0.8",
"fflate": "^0.8.3",
"jose": "^6.2.12",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.56.0",
"jspdf-autotable": "^5.0.9",
"lucide-react": "^1.59.0",
"mmdb-lib": "^3.0.3",
"next": "16.3.8",
"nodemailer": "10.1.0",
"nodemailer": "10.1.7",
"otpauth": "^9.5.2",
"p-limit": "^7.3.3",
"pg": "^8.23.1",
"pg-boss": "^12.37.1",
"prisma": "^7.10.0",
"pg-boss": "^12.38.0",
"prisma": "^7.10.1",
"qrcode": "^1.5.4",
"radix-ui": "^1.8.1",
"react": "19.3.0",
"react": "19.3.1",
"react-day-picker": "^10.0.2",
"react-dom": "19.3.0",
"react-dom": "19.3.1",
"recharts": "^3.10.1",
"rrule": "^2.8.1",
"sax": "^1.6.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"tesseract.js": "^7.0.0",
"undici": "^8.11.2",
"undici": "^8.12.2",
"web-push": "^3.6.7",
"yaml": "^2.9.1",
"zod": "^4.6.7",
@@ -105,9 +105,9 @@
"zxcvbn-typescript": "^5.0.1"
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@axe-core/playwright": "^4.14.0",
"@next/bundle-analyzer": "^16.3.8",
"@playwright/test": "^1.64.0",
"@playwright/test": "^1.64.1",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/vitest-runner": "10.0.0",
"@tailwindcss/postcss": "^4",
@@ -120,7 +120,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^5.0.4",
"@vitest/coverage-v8": "^5.0.5",
"ajv": "^8.20.0",
"eslint": "^9",
"eslint-config-next": "16.3.8",
@@ -131,11 +131,11 @@
"pdfjs-dist": "6.4.299",
"prettier": "^3.9.10",
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn": "^4.21.4",
"shadcn": "^4.22.0",
"tailwindcss": "^4",
"testcontainers": "^12.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^5.0.4"
"vitest": "^5.0.5"
}
}
+1035 -929
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -103,6 +103,13 @@ overrides:
"brace-expansion@>=4.0.0 <5.0.12": "^5.0.12"
"undici@>=7.0.0 <7.29.1": "^7.29.1"
# GHSA-587h-7wmj-h5pm (HIGH), GHSA-rq7h-c2jc-7f22, GHSA-r825-w8mm-5pxw,
# GHSA-698v-4r3x-3vcv and GHSA-9jrq-w75r-8gcw: dev-server file serving,
# cross-origin middleware and the bundled launch-editor. Vite arrives only
# under vitest, never in the image; vitest asks for ^8, so a floor on the
# same major moves it and nothing else.
"vite@>=8.0.0 <8.1.6": "^8.1.6"
allowBuilds:
"@prisma/engines": true
prisma: true
+10 -1
View File
@@ -236,6 +236,7 @@ describe("release container inputs", () => {
const dockerfile = readRepoFile("Dockerfile");
const packageJson = JSON.parse(readRepoFile("package.json")) as {
packageManager: string;
dependencies: Record<string, string>;
};
expect(packageJson.packageManager).toBe("pnpm@11.15.1");
expect(dockerfile).not.toContain("pnpm@latest");
@@ -249,7 +250,15 @@ describe("release container inputs", () => {
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx",
);
expect(dockerfile).toContain("dotenv@18.0.6");
// The image's npm-installed Prisma CLI, its engines and dotenv are a second
// install of packages the app tree already declares; they move together or
// the image runs a CLI the tests never did. Drift is how the dotenv pin
// here trailed the app's by a patch.
const floor = (name: string) =>
packageJson.dependencies[name]!.replace(/^[\^~]/, "");
expect(dockerfile).toContain(`prisma@${floor("prisma")} `);
expect(dockerfile).toContain(`@prisma/engines@${floor("prisma")} `);
expect(dockerfile).toContain(`dotenv@${floor("dotenv")} `);
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv",
);
@@ -216,11 +216,13 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/admin/backup/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/backups/run": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups/upload": { kind: "adminConsole", methods: ["POST"] },
// `/api/admin/backups/{id}/download` and `/api/admin/backups/{id}/restore`
// are published rather than exempted: both open the encrypted stored copy,
// and the refusal they answer when it will not open is a promise an operator
// needs to be able to look up. See `src/lib/openapi/routes/admin-backups.ts`.
// `/api/admin/backups/{id}/download`, `/api/admin/backups/{id}/restore` and
// `/api/admin/backups/upload` are published rather than exempted: the first
// two open the encrypted stored copy, and the refusal they answer when it
// will not open is a promise an operator needs to be able to look up; the
// upload refuses a file for reasons (a missing key, an unknown owner, a
// newer schema) an operator has to be able to look up the same way. See
// `src/lib/openapi/routes/admin-backups.ts`.
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/central-codex": {
kind: "adminConsole",
@@ -0,0 +1,58 @@
/**
* The build fetches no font from the network.
*
* `next/font/google` downloads its files from fonts.gstatic.com while the app
* builds, and that download failing is what broke image builds. Inter is
* committed under `src/app/fonts/inter/` instead, under its licence, and
* declared through `next/font/local`. This guard keeps it that way: no source
* file imports the Google loader, every file the local declarations name is
* in the tree, and each subset carries the four weights the app was built
* with.
*
* Mutation check: importing `Inter` from the Google loader anywhere under
* `src/` fails the first case; deleting a `.woff2` or dropping a weight from a
* subset fails the second.
*/
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { readSource, readSourceFolder } from "./helpers/source-files";
const ROOT = process.cwd();
const FONT_MODULE = "src/app/fonts/inter.ts";
describe("self-hosted fonts", () => {
it("imports no font loader that downloads at build time", () => {
const importing = readSourceFolder("src", {
floor: 3000,
include: (rel) => !rel.startsWith("generated/"),
extensions: [".ts", ".tsx", ".mts", ".js", ".mjs"],
})
.filter(([, text]) => /from\s+["']next\/font\/google["']/.test(text))
.map(([rel]) => rel);
expect(importing).toEqual([]);
});
it("ships every file the declarations name, with its licence and four weights", () => {
const source = readSource(FONT_MODULE, ["next/font/local", "localFont("]);
const entries = [
...source.matchAll(/path:\s*"([^"]+)",\s*weight:\s*"(\d+)"/g),
].map(([, path, weight]) => ({ path: path!, weight: weight! }));
expect(entries.length).toBe(28);
const byFile = new Map<string, string[]>();
for (const { path, weight } of entries) {
byFile.set(path, [...(byFile.get(path) ?? []), weight]);
}
expect(byFile.size).toBe(7);
for (const [path, weights] of byFile) {
expect(existsSync(join(ROOT, dirname(FONT_MODULE), path)), path).toBe(
true,
);
expect(weights, path).toEqual(["400", "500", "600", "700"]);
}
expect(existsSync(join(ROOT, "src/app/fonts/inter/OFL.txt"))).toBe(true);
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* Inter, self-hosted. The build fetches nothing from the network for it.
*
* These are the files `next/font/google` used to download at build time for
* `Inter({ subsets: ["latin"], weight: ["400", "500", "600", "700"] })`,
* byte for byte: Google serves Inter 4.001 as one variable file per script
* subset, the same file for every weight. That download failed often enough
* to break image builds, so the files live here under their licence
* (`./inter/OFL.txt`) and the declarations below rebuild the stylesheet
* Google answered: twenty-eight faces, the four weights for each of the seven
* subsets with Google's `unicode-range`s, in Google's order (where two ranges
* overlap the face declared later wins, so the order is part of the result),
* `font-display: swap`, and only the Latin file preloaded.
*
* `next/font/local` emits one `unicode-range` per call, hence one call per
* subset, all declaring the family `Inter`. The fallback face and the
* `--font-sans` stack are in `globals.css`: the local loader would derive the
* Arial metric overrides from the font file, and they come out a little
* different from the ones next/font/google shipped, which are kept as they
* were.
*
* Imported by the root layout for its stylesheet only.
*/
import localFont from "next/font/local";
// `next/font/local` is evaluated at build time from the literal arguments of
// each call, so every option below has to be written out in place.
// The bindings are never read: importing this module is what attaches the
// faces, so each name carries the leading underscore of an intended unused.
const _interCyrillicExt = localFont({
src: [
{
path: "./inter/inter-cyrillic-ext.woff2",
weight: "400",
style: "normal",
},
{
path: "./inter/inter-cyrillic-ext.woff2",
weight: "500",
style: "normal",
},
{
path: "./inter/inter-cyrillic-ext.woff2",
weight: "600",
style: "normal",
},
{
path: "./inter/inter-cyrillic-ext.woff2",
weight: "700",
style: "normal",
},
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F",
},
],
});
const _interCyrillic = localFont({
src: [
{ path: "./inter/inter-cyrillic.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-cyrillic.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-cyrillic.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-cyrillic.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value: "U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116",
},
],
});
const _interGreekExt = localFont({
src: [
{ path: "./inter/inter-greek-ext.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-greek-ext.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-greek-ext.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-greek-ext.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{ prop: "unicode-range", value: "U+1F00-1FFF" },
],
});
const _interGreek = localFont({
src: [
{ path: "./inter/inter-greek.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-greek.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-greek.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-greek.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF",
},
],
});
const _interVietnamese = localFont({
src: [
{ path: "./inter/inter-vietnamese.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-vietnamese.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-vietnamese.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-vietnamese.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB",
},
],
});
const _interLatinExt = localFont({
src: [
{ path: "./inter/inter-latin-ext.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-latin-ext.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-latin-ext.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-latin-ext.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF",
},
],
});
const _interLatin = localFont({
src: [
{ path: "./inter/inter-latin.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-latin.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-latin.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-latin.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: true,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD",
},
],
});
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
View File
@@ -30,6 +30,28 @@
@source not "../../.planning";
@source not "../../docs";
/*
* Inter's fallback and the font-sans stack. The Inter faces themselves come
* from `./fonts/inter.ts`. This is what next/font/google generated for Inter
* when it still fetched the font at build time: Arial, scaled with the metric
* overrides it shipped for Inter, so text keeps its box while the web font
* loads. They are written out here because next/font/local would compute its
* own, slightly different overrides from the font file.
*/
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
ascent-override: 90.44%;
descent-override: 22.52%;
line-gap-override: 0%;
size-adjust: 107.12%;
}
/* On body, where the next/font variable class used to set it. */
body {
--font-sans: "Inter", "Inter Fallback";
}
@custom-variant dark (&:is(.dark *));
/*
+4 -8
View File
@@ -1,7 +1,9 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { headers } from "next/headers";
import Script from "next/script";
// Inter's faces, self-hosted; see the module. Before globals.css, where the
// font-sans stack that names them lives, as the next/font/google import was.
import "./fonts/inter";
import "./globals.css";
import { Providers } from "@/components/providers";
import { AuthShell } from "@/components/layout/auth-shell";
@@ -12,12 +14,6 @@ import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
import { THEME_BOOT_SCRIPT, THEME_COLOR } from "@/lib/pwa/theme-color";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "HealthLog",
description:
@@ -136,7 +132,7 @@ export default async function RootLayout({
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_I18N_CATALOG_VERSION || "dev"}`}
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<body className="font-sans antialiased">
{isKeyMismatch() ? (
// The boot key check refused this process: no app shell, no data
// reads, one page that names the fix.
@@ -23,9 +23,10 @@ import { primeLocales } from "@/__tests__/helpers/prime-locales";
* REAL compiled stylesheet, lays it out in a browser at the real column
* width, and reads the boxes back.
*
* What it does NOT do is pretend to know the shipped typeface. The app loads
* Inter through `next/font/google` at build time; no font file is committed,
* so a headless browser here resolves the `font-sans` fallback chain instead.
* What it does NOT do is pretend to know the shipped typeface. The app's Inter
* faces are declared through `next/font/local` (`src/app/fonts/inter.ts`) and
* only exist in a Next build, so a headless browser here resolves the
* `font-sans` fallback chain instead.
* Every assertion below is therefore written to be font-independent — a
* relative comparison inside one run, or a structural property (does the row
* push, does the label ellipsise, is the bar still there) that holds in any
@@ -18,7 +18,7 @@ describe("pg-boss failed-row ledger retention", () => {
) as { version: string };
const plans = readFileSync(join(PG_BOSS_ROOT, "dist/plans.js"), "utf8");
expect(packageJson.version).toBe("12.37.1");
expect(packageJson.version).toBe("12.38.0");
expect(plans).toContain("retention_seconds: FORTEEN_DAYS");
expect(plans).toContain("deletion_seconds: SEVEN_DAYS");
// 12.37 reads the clock through job_now(), which is pg_catalog.now()
@@ -0,0 +1,134 @@
/**
* The generic auth gate's 403, declared on every operation that passes it.
*
* `requireAuth` answers 403 `auth.scope.insufficient` to a narrow Bearer token
* on a route that names no scope it holds, and `requireAdmin` answers 403
* `auth.admin.required` to a session that is not an administrator's. Both run
* before a route's own body, so every operation behind them can send the
* status; `withScopeRefusal` declares it on the assembled table. These checks
* hold the published document to that, and hold the helper to not trampling
* the 403s operations already publish for reasons of their own.
*/
import { describe, expect, it } from "vitest";
import { openApiPaths } from "../routes";
import {
adminRefusalResponse,
errorEnvelope,
scopeRefusalResponse,
stdResponses,
withScopeRefusal,
} from "../routes/shared";
const METHODS = ["get", "put", "post", "patch", "delete"] as const;
interface Op {
security?: unknown[];
responses?: Record<string, { description?: string } | undefined>;
}
function operations(): Array<{ path: string; method: string; op: Op }> {
const out: Array<{ path: string; method: string; op: Op }> = [];
for (const [path, item] of Object.entries(openApiPaths)) {
for (const method of METHODS) {
const op = (item as Record<string, Op | undefined>)[method];
if (op?.responses) out.push({ path, method, op });
}
}
return out;
}
const URL_CREDENTIAL_OR_OWN_GATE = new Set([
"/api/c/{token}/unlock",
"/api/notifications/action/{token}",
"/api/ingest/medication",
]);
describe("the gate's 403 in the published contract", () => {
it("is declared, naming its code, on every operation behind the gate", () => {
const behind = operations().filter(
({ path, op }) =>
op.responses?.["401"] &&
!(Array.isArray(op.security) && op.security.length === 0) &&
!URL_CREDENTIAL_OR_OWN_GATE.has(path),
);
expect(behind.length).toBeGreaterThan(500);
const silent = behind.filter(({ path, op }) => {
const code = path.startsWith("/api/admin/")
? "auth.admin.required"
: "auth.scope.insufficient";
return !op.responses?.["403"]?.description?.includes(code);
});
expect(silent.map(({ path, method }) => `${method} ${path}`)).toEqual([]);
});
it("is not declared on an operation that takes no credential", () => {
const open = operations().filter(
({ op }) => Array.isArray(op.security) && op.security.length === 0,
);
expect(open.length).toBeGreaterThan(0);
const claimed = open.filter(({ op }) =>
op.responses?.["403"]?.description?.includes("auth.scope.insufficient"),
);
expect(claimed.map(({ path, method }) => `${method} ${path}`)).toEqual([]);
});
it("keeps an operation's own 403 and its wording", () => {
const calendar = (
openApiPaths["/api/cycle/calendar"] as unknown as {
get: Op;
}
).get;
const description = calendar.responses?.["403"]?.description ?? "";
expect(description).toContain("cycle.disabled");
expect(description).toContain("auth.scope.insufficient");
});
});
describe("withScopeRefusal", () => {
const ownForbidden = {
description: "Module off. `meta.errorCode` = `module.disabled`.",
content: { "application/json": { schema: errorEnvelope } },
};
const table = withScopeRefusal({
"/api/a": {
get: { responses: { "200": {}, ...stdResponses } },
post: { responses: { "200": {}, ...stdResponses, "403": ownForbidden } },
},
"/api/b": {
get: { responses: { "200": {}, "403": ownForbidden, ...stdResponses } },
},
"/api/admin/x": { get: { responses: { "200": {}, ...stdResponses } } },
"/api/open": {
get: { security: [], responses: { "200": {}, ...stdResponses } },
},
"/api/no-auth-declared": { get: { responses: { "200": {} } } },
}) as unknown as Record<string, Record<string, Op>>;
it("adds the shared response where an operation has none", () => {
expect(table["/api/a"].get.responses?.["403"]).toBe(scopeRefusalResponse);
expect(table["/api/admin/x"].get.responses?.["403"]).toBe(
adminRefusalResponse,
);
});
it("prefixes an own 403, once per shared object, and leaves its text", () => {
const a = table["/api/a"].post.responses?.["403"];
const b = table["/api/b"].get.responses?.["403"];
expect(a).toBe(b);
expect(a?.description?.startsWith("`meta.errorCode` = `auth.scope")).toBe(
true,
);
expect(a?.description?.endsWith(ownForbidden.description)).toBe(true);
// The input object is not mutated.
expect(ownForbidden.description).not.toContain("auth.scope");
});
it("leaves an operation that takes no credential or declares no 401 alone", () => {
expect(table["/api/open"].get.responses?.["403"]).toBeUndefined();
expect(
table["/api/no-auth-declared"].get.responses?.["403"],
).toBeUndefined();
});
});
+108 -4
View File
@@ -1,13 +1,13 @@
/**
* OpenAPI route table — the backups-console routes that read a stored copy, and
* the restore job they start.
* the restore job they start, and the upload that stores a copy to restore.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
*
* Most of `/api/admin/backups/*` is deliberately unpublished: it is a
* cookie-only console, `requireAdmin()` refuses every Bearer caller, and a
* contract nobody can call against is a contract nobody reads. These two are
* here for the opposite reason. Both open the encrypted stored copy (pieces in
* contract nobody can call against is a contract nobody reads. The two readers
* are here for the opposite reason. Both open the encrypted stored copy (pieces in
* `data_backup_chunks`, or one value in `data_backups.data` for a copy written
* before v1.39.2), and both therefore have a refusal an operator meets on
* the day a key rotation went one step too far — the case
@@ -16,6 +16,11 @@
* the handler happens to answer, and a promise belongs somewhere it can be
* quoted from.
*
* The upload is here for the same reason: it refuses a file the operator has
* in hand for reasons they have to be able to look up (a key this server does
* not hold, an owner that does not exist here, a newer schema), and its answer
* is the copy id the restore takes.
*
* The restore answers 202 and runs as a background job, so the two routes that
* report on that job are published with it: a 202 without its status route is
* half a contract.
@@ -23,7 +28,12 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { dataEnvelope, errorEnvelope, idempotencyKeyParameter } from "./shared";
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
stdResponses,
} from "./shared";
const restoreRequest = z
.object({
@@ -168,7 +178,101 @@ const notFoundResponse = {
content: { "application/json": { schema: errorEnvelope } },
};
const uploadSummary = z
.object({
schemaVersion: z.string(),
userId: z.string(),
exportedAt: z.string(),
})
.catchall(z.number().int())
.meta({
id: "AdminBackupUploadSummary",
description:
"What the stored file holds: its `schemaVersion`, the account it was exported from (`userId`) and when (`exportedAt`), and one count per serialised class (`measurements`, `medications`, `intakeEvents`, `moodEntries`, …). The set of counts grows with the backup format; read the ones you know and show the rest generically.",
});
const uploadStored = z
.object({
id: z.string().meta({
description:
"`DataBackup.id` of the stored copy; pass it to `POST /api/admin/backups/{id}/restore`.",
}),
valid: z.literal(true),
summary: uploadSummary,
})
.meta({
id: "AdminBackupUploadResponse",
description:
"The file was read, checked and stored as a new copy. Nothing was restored.",
});
export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/admin/backups/upload": {
post: {
tags: ["Admin"],
summary: "Store an uploaded backup file as a new copy",
description:
"Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
requestBody: {
required: true,
content: {
"application/json": {
schema: z.record(z.string(), z.unknown()).meta({
description:
"The backup document, as `GET /api/admin/backups/{id}/download` or the account export wrote it.",
}),
},
"application/gzip": {
schema: z.string().meta({ format: "binary" }),
},
"multipart/form-data": {
schema: z.object({
file: z.string().meta({
format: "binary",
description:
"The backup file, plain or gzip-compressed, up to 10 MB.",
}),
}),
},
},
},
responses: {
"201": {
description: "The file was stored as a new copy.",
content: {
"application/json": {
schema: dataEnvelope(uploadStored, "AdminBackupUploadEnvelope"),
},
},
},
"400": {
description:
"A `multipart/form-data` body that could not be parsed as a form. Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
"401": {
description:
"No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with `meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description:
"Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = `backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description:
"The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist here.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
...stdResponses["429"],
description:
"More than three uploads in a minute by this administrator. Nothing was stored. The `Retry-After` and `X-RateLimit-*` headers describe the bucket.",
},
},
},
},
"/api/admin/backups/{id}/download": {
get: {
tags: ["Admin"],
+45
View File
@@ -15,10 +15,44 @@ import {
import {
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
recordRefusal,
stdResponses,
} from "./shared";
// The FHIR face answers its own refusals as FHIR: a 400, 404 or 429 raised
// by a `/api/fhir/*` handler is an `OperationOutcome` served as
// `application/fhir+json` (`operationOutcome` in `@/lib/fhir/rest`), which is
// what a FHIR client parses on any non-2xx. The refusals that run before the
// handler's own body are the API's: the generic 401 / 403 envelopes and the
// 403 `module.disabled`, which keeps the errorCode every client already
// branches on for a disabled module. Declared here after `...stdResponses`,
// so the FHIR shapes replace the generic JSON ones.
const fhirOutcomeContent = {
"application/fhir+json": {
schema: z.string().meta({ format: "binary" }),
},
};
const fhirDataRefusals = {
...moduleDisabledResponse,
"429": {
description:
"Rate limit exceeded: the per-account `fhir:<accountId>` bucket, 120 requests per hour, shared by every `/api/fhir/*` data route. The body is an `OperationOutcome` (`issue[0].code` = `throttled`) served as `application/fhir+json`, not the JSON error envelope. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe the bucket; back off on `Retry-After`.",
content: fhirOutcomeContent,
headers: stdResponses["429"].headers,
},
};
const fhirSearchRefusals = {
...fhirDataRefusals,
"400": {
description:
"A search parameter the server cannot honour, such as an `_offset` above 9007199254740991. The body is an `OperationOutcome` (`issue[0].code` = `invalid`, the reason in `diagnostics`) served as `application/fhir+json`.",
content: fhirOutcomeContent,
},
};
// The two request bodies this module publishes. `.meta()` CLONES in Zod 4
// rather than annotating in place, so the returned schema has to be captured
// and referenced: a bare `schema.meta({...})` statement registers nothing and
@@ -394,6 +428,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -426,6 +461,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -456,6 +492,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -486,6 +523,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -516,6 +554,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -546,6 +585,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -576,6 +616,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -606,6 +647,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -636,6 +678,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -666,6 +709,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -696,6 +740,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
+7 -2
View File
@@ -96,9 +96,9 @@ import { settingsPaths } from "./settings";
import { symptomPaths } from "./symptoms";
import { syncPaths } from "./sync";
import { workoutPaths } from "./workouts";
import { coachPrefsSchema } from "./shared";
import { coachPrefsSchema, withScopeRefusal } from "./shared";
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
const registeredPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...cyclePaths,
...metaPaths,
...healthRecordPaths,
@@ -214,6 +214,11 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...retiredPaths,
};
// The generic auth gate's 403 is declared on the assembled table, not spread
// from `stdResponses`; `scopeRefusalResponse` in `./shared` says why.
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> =
withScopeRefusal(registeredPaths);
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
// Schemas listed here are forced into `components.schemas` even when no
// route references them directly. `Medication` lives in this slot
+29 -24
View File
@@ -1141,6 +1141,29 @@ const profileSummaryResponse = z
"The bounded profile view a record viewer gets: allergies, family history and current lifestyle facts. Structurally incapable of becoming a Settings or AI surface.",
});
/**
* What the single-value `/api/auth/me/*` writes answer for a body they refuse
* to read. Each reads through `safeJson` with a 1 KB cap, so the three
* refusals are the cap's, not the route's: a wrong content type is 415, a body
* over the cap 413, and one that does not parse 400. All three are answered
* before validation, and nothing is written.
*/
const cappedScalarBodyRefusals = {
"400": {
description: "The request body is not parseable JSON. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description:
"The request did not carry `Content-Type: application/json`. Nothing was read and nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/profile/summary": {
get: {
@@ -1863,10 +1886,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -1917,10 +1937,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -1971,10 +1988,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -2257,10 +2271,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -2307,10 +2318,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -2360,10 +2368,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
+122
View File
@@ -351,6 +351,128 @@ export const stdResponses = {
},
};
// ── The generic 403 ──────────────────────────────────────────────────
/**
* The 403 the generic auth gates answer before a route's own body runs.
*
* `requireAuth` refuses a narrow Bearer token on a route that names no scope
* the token holds (`auth.scope.insufficient`), and `requireAdmin` refuses a
* session that is not an administrator's (`auth.admin.required`). The shared
* 401 described both in its prose and no operation declared the status, so a
* decoder generated from the contract had no typed branch for a refusal any
* authenticated route can send.
*
* Neither is a member of `stdResponses`, on purpose. That set is spread into
* several hundred operations, about three hundred of them declare a 403 of
* their own, and most spread the set AFTER their own entries — a shared 403
* in it would silently overwrite every module-off and sharing refusal in the
* document. `withScopeRefusal` adds them to the assembled table instead, where
* the order of a spread cannot decide it.
*/
export const scopeRefusalResponse = {
description:
"The credential is valid but its scope does not reach this operation. `meta.errorCode` = `auth.scope.insufficient`: a narrow Bearer token on a route that names no scope it holds, or a connector-bound token on a write. Retrying with the same token will not help; use a session, or a token minted with the scope the route names.",
content: { "application/json": { schema: errorEnvelope } },
};
/**
* The admin surfaces' variant. `requireAdmin` reads only the session cookie,
* so a Bearer caller never gets this far (it is a 401 there); the 403 is a
* signed-in account that is not an administrator.
*/
export const adminRefusalResponse = {
description:
"The session is valid but its account is not an administrator. `meta.errorCode` = `auth.admin.required`.",
content: { "application/json": { schema: errorEnvelope } },
};
const GATE_SENTENCE = {
scope:
"`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: ",
admin:
"`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin gate answers it before the route's own checks run. Otherwise: ",
} as const;
const GATE_CODE = {
scope: "auth.scope.insufficient",
admin: "auth.admin.required",
} as const;
/**
* Surfaces that authenticate by a credential of their own and never pass the
* generic gate, so they cannot answer its 403: a URL credential (the
* clinician share link, the notification quick answer) or the external ingest
* token, which runs its own scope check and documents its own 403.
*/
const OUTSIDE_THE_GENERIC_GATE = new Set([
"/api/c/{token}/unlock",
"/api/notifications/action/{token}",
"/api/ingest/medication",
]);
/**
* Declare the gate's 403 on every operation that passes the generic auth
* gate: one that declares a 401 and does not opt out of authentication with
* `security: []`. Under `/api/admin/` the gate is `requireAdmin`, everywhere
* else `requireAuth`. An operation without a 403 of its own gets the shared
* response; one with its own keeps it, and its description is prefixed with
* the gate's code, because a client branching on that 403 has to tell a
* module-off refusal from a gate refusal. Prefixed, not appended: the sharing
* refusal has to stay the description's closing sentence, byte for byte
* (`openapi-sharing-denial.test.ts`).
*
* Response objects shared between operations stay shared (one augmented copy
* per original), so the emitted document keeps its anchors.
*/
export function withScopeRefusal<T extends Record<string, unknown>>(
paths: T,
): T {
const augmented = {
scope: new WeakMap<object, object>(),
admin: new WeakMap<object, object>(),
};
const out: Record<string, unknown> = {};
for (const [path, item] of Object.entries(paths)) {
if (!isPlainRecord(item) || OUTSIDE_THE_GENERIC_GATE.has(path)) {
out[path] = item;
continue;
}
const gate = path.startsWith("/api/admin/") ? "admin" : "scope";
const nextItem: Record<string, unknown> = { ...item };
for (const method of ["get", "put", "post", "patch", "delete"] as const) {
const op = item[method];
if (!isPlainRecord(op) || !isPlainRecord(op.responses)) continue;
if (Array.isArray(op.security) && op.security.length === 0) continue;
if (!op.responses["401"]) continue;
const own = op.responses["403"];
let forbidden: unknown =
gate === "admin" ? adminRefusalResponse : scopeRefusalResponse;
if (isPlainRecord(own)) {
forbidden = augmented[gate].get(own);
if (!forbidden) {
const description =
typeof own.description === "string" ? own.description : "";
forbidden = description.includes(GATE_CODE[gate])
? own
: { ...own, description: GATE_SENTENCE[gate] + description };
augmented[gate].set(own, forbidden as object);
}
}
nextItem[method] = {
...op,
responses: { ...op.responses, "403": forbidden },
};
}
out[path] = nextItem;
}
return out as T;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* The 429 the shared single-record write bucket answers with.
*
@@ -0,0 +1,140 @@
/**
* What Strava's token endpoint answers when it refuses, and what the sync
* makes of it.
*
* Strava does not answer RFC 6749 errors. A refused refresh is the API's
* generic `Fault`; the bodies below are the ones the endpoint sends, as
* captured from live traffic by several unrelated client libraries (Strava
* documents the `Fault` / `Error` model, not these particular values). The
* HTTP layer is the only thing replaced: `postToken`, the error it throws and
* the classifier all run as they do in production.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() }));
import { safeFetch } from "@/lib/safe-fetch";
import { exchangeCode, refreshAccessToken } from "../client";
import {
classifyStravaError,
stravaFaultSummary,
StravaApiError,
} from "../response-classifier";
import { classifyStravaFailure } from "../sync";
const creds = { clientId: "12345", clientSecret: "not-a-real-secret" };
const DEAD_REFRESH_TOKEN = {
message: "Bad Request",
errors: [
{ resource: "RefreshToken", field: "refresh_token", code: "invalid" },
],
};
const BAD_CLIENT_ID = {
message: "Bad Request",
errors: [{ resource: "Application", field: "client_id", code: "invalid" }],
};
const BAD_CLIENT = {
message: "Authorization Error",
errors: [{ resource: "Application", field: "", code: "invalid" }],
};
function answer(status: number, body: unknown) {
vi.mocked(safeFetch).mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
);
}
async function refusal(run: () => Promise<unknown>): Promise<StravaApiError> {
const err = await run().then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(StravaApiError);
return err as StravaApiError;
}
beforeEach(() => {
vi.mocked(safeFetch).mockReset();
});
describe("a refused Strava token request", () => {
it("asks for a reconnect when the refresh token is dead", async () => {
answer(400, DEAD_REFRESH_TOKEN);
const err = await refusal(() => refreshAccessToken("rt-dead", creds));
expect(err.httpStatus).toBe(400);
expect(err.upstreamError).toBe("RefreshToken.refresh_token:invalid");
expect(classifyStravaError(err)).toBe("reauth_required");
expect(classifyStravaFailure(err)).toBe("reauth_required");
});
it("still asks for a reconnect when the error lost its prototype on a retry", async () => {
answer(400, DEAD_REFRESH_TOKEN);
const err = await refusal(() => refreshAccessToken("rt-dead", creds));
const replayed = new Error(err.message);
expect(replayed.message).toBe(
"Strava refreshAccessToken error: 400 - RefreshToken.refresh_token:invalid",
);
expect(classifyStravaError(replayed)).toBe("reauth_required");
});
it("leaves a fault that names the application to the status, as before", async () => {
// A client id or secret the instance holds wrongly. Whether Strava ever
// answers a per-person condition this way is not documented, so the
// status decides, as it always has: 400 persistent, 401 reconnect.
answer(400, BAD_CLIENT_ID);
const bad = await refusal(() => refreshAccessToken("rt-live", creds));
expect(bad.upstreamError).toBe("Application.client_id:invalid");
expect(classifyStravaError(bad)).toBe("persistent");
answer(401, BAD_CLIENT);
const unauthorised = await refusal(() =>
refreshAccessToken("rt-live", creds),
);
expect(unauthorised.upstreamError).toBe("Application:invalid");
expect(classifyStravaError(unauthorised)).toBe("reauth_required");
});
it("leaves a 400 that names neither as persistent and a 5xx as transient", async () => {
answer(400, { message: "Bad Request", errors: [] });
const plain = await refusal(() => exchangeCode("code", creds));
expect(plain.upstreamError).toBe("Bad Request");
expect(classifyStravaError(plain)).toBe("persistent");
answer(503, { message: "Service Unavailable" });
const outage = await refusal(() => refreshAccessToken("rt-live", creds));
expect(classifyStravaError(outage)).toBe("transient");
});
it("still honours a standards-shaped invalid_grant", async () => {
answer(400, { error: "invalid_grant" });
const err = await refusal(() => refreshAccessToken("rt-dead", creds));
expect(err.upstreamError).toBe("invalid_grant");
expect(classifyStravaError(err)).toBe("reauth_required");
});
});
describe("stravaFaultSummary", () => {
it("names only the resource, field and code, and drops anything else", () => {
expect(
stravaFaultSummary({
message: "Bad Request",
errors: [
{
resource: "RefreshToken",
field: "refresh_token<script>",
code: "invalid",
},
],
}),
).toBe("RefreshToken:invalid");
expect(stravaFaultSummary(null)).toBeUndefined();
expect(stravaFaultSummary({ errors: "nope" })).toBeUndefined();
});
});
+8 -3
View File
@@ -25,7 +25,11 @@ import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { parseRetryAfterMs } from "@/lib/integrations/oauth-kit/retry-after";
import type { Prisma } from "@/generated/prisma/client";
import { StravaApiError, classifyStravaResponse } from "./response-classifier";
import {
StravaApiError,
classifyStravaResponse,
stravaFaultSummary,
} from "./response-classifier";
import { mapStravaSportType } from "./sport-map";
import type { WorkoutSportType } from "@/lib/validations/workout";
import { envOr, envValue } from "@/lib/env";
@@ -128,8 +132,9 @@ async function postToken(
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError:
typeof json?.message === "string" ? json.message : undefined,
// Strava's `Fault`, reduced to the resource it names; the classifier
// tells a dead refresh token from a misconfigured app by it.
upstreamError: stravaFaultSummary(json),
});
}
return json as StravaTokenResponse;
+66 -9
View File
@@ -7,8 +7,9 @@
* - `success` → HTTP 2xx
* - `transient` → 429 (rate-limited — respect X-RateLimit headers),
* 5xx, 3xx, unknown.
* - `reauth_required` → 401 / 403 — token rejected / grant revoked, plus an
* `invalid_grant` on the refresh endpoint.
* - `reauth_required` → 401 / 403 — token rejected / grant revoked, plus a
* dead refresh token on the token endpoint (see
* `isStravaDeadRefreshToken`).
* - `persistent` → any other 4xx — malformed request / contract drift.
*/
@@ -55,16 +56,72 @@ export function isStravaRateLimited(err: unknown): err is StravaApiError {
}
/**
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* the canonical revoked / rotated-away refresh-token signal. Lifts a 400
* carrying `invalid_grant` from `persistent` to `reauth_required` so the
* connection surfaces the reconnect prompt.
* Strava's token endpoint does not answer in RFC 6749 form. A refusal is the
* API's generic `Fault` (developers.strava.com/docs/reference, "Fault" and
* "Error"): `{"message":"Bad Request","errors":[{"resource":"RefreshToken",
* "field":"refresh_token","code":"invalid"}]}`, with no `error` member at all,
* so an `invalid_grant` check never matches it. This reduces the fault to one
* stable token for the error message — `<resource>.<field>:<code>` of the first
* entry, e.g. `RefreshToken.refresh_token:invalid` or `Application:invalid` —
* and falls back to an RFC 6749 `error` code, then to `message`. Nothing of
* the body beyond those names reaches the message.
*/
function isInvalidGrant(err: unknown): boolean {
return isOAuthInvalidGrant(err, "Strava");
export function stravaFaultSummary(body: unknown): string | undefined {
if (typeof body !== "object" || body === null) return undefined;
const fault = body as {
message?: unknown;
errors?: unknown;
error?: unknown;
};
// A standards-shaped body (`{"error":"invalid_grant"}`) keeps its code.
if (typeof fault.error === "string" && /^[a-z_]{1,40}$/.test(fault.error)) {
return fault.error;
}
const first = Array.isArray(fault.errors) ? fault.errors[0] : undefined;
if (typeof first === "object" && first !== null) {
const { resource, field, code } = first as Record<string, unknown>;
if (typeof resource === "string" && /^[A-Za-z]{1,40}$/.test(resource)) {
const at =
typeof field === "string" && /^[A-Za-z_]{1,40}$/.test(field)
? `.${field}`
: "";
const why =
typeof code === "string" && /^[A-Za-z_]{1,40}$/.test(code)
? `:${code}`
: "";
return `${resource}${at}${why}`;
}
}
return typeof fault.message === "string"
? fault.message.slice(0, 120)
: undefined;
}
/**
* True for a refresh token Strava no longer honours: a 400 whose fault names
* the `RefreshToken` resource. That is what the token endpoint answers once the
* athlete revoked the app or the token was rotated away, and the only way back
* is a new authorisation, so it lifts the verdict to `reauth_required`. A
* standards-shaped `invalid_grant` is accepted too, in case Strava ever sends
* one.
*
* Robust to a lost prototype across a pg-boss retry, like
* `isOAuthInvalidGrant`: it falls back to the
* `"Strava <verb> error: 400 - RefreshToken…"` message shape.
*/
export function isStravaDeadRefreshToken(err: unknown): boolean {
if (isOAuthInvalidGrant(err, "Strava")) return true;
if (err instanceof IntegrationApiError) {
return (
err.httpStatus === 400 &&
(err.upstreamError?.startsWith("RefreshToken") ?? false)
);
}
const msg = err instanceof Error ? err.message : String(err);
return /Strava\s+\w+\s+error:\s*400\s+-\s+RefreshToken\b/.test(msg);
}
export function classifyStravaError(err: unknown): IntegrationClassification {
if (isInvalidGrant(err)) return "reauth_required";
if (isStravaDeadRefreshToken(err)) return "reauth_required";
return classifyIntegrationError(err, "Strava");
}
+3 -2
View File
@@ -15,8 +15,9 @@
* Token model: Strava ROTATES its refresh token on every refresh. The schema
* has no expiry column, so the sync refreshes REACTIVELY — the first read that
* 401s triggers one refresh (persisting BOTH rotated tokens via a compare-and-
* set) and a single retry. A failed refresh (`invalid_grant`) records
* `reauth_required` on the `strava` ledger.
* set) and a single retry. A refresh Strava refuses for a dead token (a 400
* whose fault names `RefreshToken`) records `reauth_required` on the `strava`
* ledger; any other refusal is classified by its status.
*
* Rate limits: 200 req / 15 min + 2000 / day per app. The walk is page-bounded
* (`walkPages`, which reports a walk cut off at its ceiling instead of
@@ -335,5 +335,12 @@ describe("dashboard all-time counts — parity with the live count", () => {
expect(legacy.get("PULSE")).toBeGreaterThan(500_000);
expect(legacy.get("WEIGHT")).toBe(5);
expect(legacy.get("BLOOD_GLUCOSE")).toBe(2);
});
// Over half a million rows written and folded into DAY buckets: about
// 35 s on a CI runner when the file ran alone. With three files sharing
// the runner's Postgres it took 1.7 times as long (measured with the
// database capped at two CPUs, HEALTHLOG_INTEGRATION_PG_CPUS=2: 14.6 s
// alone, 25.3 s beside the sharing matrix and the restore memory test),
// past the suite's 60 s default. The work is the point of the case, so
// the budget is sized to it: twice the contended estimate.
}, 150_000);
});
+16 -8
View File
@@ -44,7 +44,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PROJECT_ROOT = resolve(__dirname, "..", "..");
let container: StartedPostgreSqlContainer | null = null;
let started: StartedPostgreSqlContainer | null = null;
declare module "vitest" {
export interface ProvidedContext {
@@ -56,7 +56,7 @@ declare module "vitest" {
export default async function setup(
project: TestProject,
): Promise<() => Promise<void>> {
container = await new PostgreSqlContainer("postgres:16-alpine")
let container = new PostgreSqlContainer("postgres:16-alpine")
.withDatabase(TEMPLATE_DATABASE)
.withUsername("healthlog")
.withPassword("healthlog")
@@ -74,10 +74,18 @@ export default async function setup(
"full_page_writes=off",
"-c",
"max_connections=400",
])
.start();
]);
// A CPU quota for the database container, to reproduce a CI runner's
// contention on a larger machine: HEALTHLOG_INTEGRATION_PG_CPUS=2.
const cpus = Number.parseFloat(
process.env.HEALTHLOG_INTEGRATION_PG_CPUS ?? "",
);
if (Number.isFinite(cpus) && cpus > 0) {
container = container.withResourcesQuota({ cpu: cpus });
}
started = await container.start();
const url = container.getConnectionUri() + "?schema=public&pgbouncer=false";
const url = started.getConnectionUri() + "?schema=public&pgbouncer=false";
process.env.DATABASE_URL = url;
// The explicit child environment keeps Prisma CLI on the same container.
@@ -91,9 +99,9 @@ export default async function setup(
project.provide("integrationDatabasePlan", await readTemplate(url));
return async () => {
if (container) {
await container.stop({ remove: true });
container = null;
if (started) {
await started.stop({ remove: true });
started = null;
}
};
}
@@ -2,27 +2,21 @@
* The full-history admission lane against a real pg-boss on a real Postgres:
* what happens to an import that runs past its job's expiry.
*
* pg-boss 12.34, measured with the lane's worker shape (localConcurrency 1,
* Up to 12.37, measured with the lane's worker shape (localConcurrency 1,
* groupConcurrency 1, exclusive policy, retries with backoff), a two-second
* expiry, a one-second retry delay and a handler that ignores the abort
* signal and runs for eight seconds:
* signal and runs for eight seconds, pg-boss failed the job at the expiry,
* fired the signal, freed the worker and group slots, and started the retry
* in the same worker BESIDE the run it had given up on: two runs of one job
* at once, which the lane's lock existed to absorb.
*
* +5ms start #1 (job 1709c8a6…) running=1
* +2008ms abort signal #1
* +4051ms start #2 (same job id) running=2
* +6054ms abort signal #2
* +8007ms end #1 running=1
* +9593ms start #3 (same job id) running=2
* +12053ms end #2
* +17595ms end #3
* +19166ms start #4 (same job id) running=1
*
* At the expiry pg-boss fails the job, fires the signal, frees the worker and
* group slots, and retries the job after its delay; the handler keeps going.
* The retry then starts in the same worker beside the run it replaced, and
* the lane's one-at-a-time holds for everything except the import too long
* for its expiry. The first test keeps that as an executable statement about
* pg-boss; the second runs the lane's own handler under the same conditions.
* 12.38 changed that. At the expiry it still fails the job and fires the
* signal, but the worker fetches nothing more until the handler returns (and
* records a `handler_overrun` warning). The retry starts after the overrun
* ends, in the same worker, so the lane's one-at-a-time now holds without the
* lock's help; the lock stays as the guard for a second worker or instance.
* The first test keeps that as an executable statement about pg-boss; the
* second runs the lane's own handler under the same conditions.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { PgBoss, type Job } from "pg-boss";
@@ -84,18 +78,23 @@ afterAll(async () => {
});
describe("an import that outlives its expiry", () => {
it("pg-boss starts the retry of the same job beside the run it expired", async () => {
it("pg-boss retries the expired job only once the run it gave up on returns", async () => {
const queue = `lane-expiry-raw-${suffix}`;
await boss.createQueue(queue, { policy: "exclusive" });
const ids: string[] = [];
const runs: Array<{ id: string; start: number; end?: number }> = [];
let running = 0;
let maxRunning = 0;
await boss.work(queue, WORK_OPTIONS, async ([job]: Job<object>[]) => {
ids.push(job!.id);
const run: { id: string; start: number; end?: number } = {
id: job!.id,
start: Date.now(),
};
runs.push(run);
running += 1;
maxRunning = Math.max(maxRunning, running);
await sleep(8_000);
running -= 1;
run.end = Date.now();
});
await boss.send(queue, payload, {
...integrationBackfillAdmissionSendOptions(payload),
@@ -105,12 +104,13 @@ describe("an import that outlives its expiry", () => {
await sleep(12_000);
await boss.offWork(queue);
expect(ids.length).toBeGreaterThanOrEqual(2);
expect(new Set(ids).size).toBe(1);
expect(maxRunning).toBeGreaterThanOrEqual(2);
expect(runs.length).toBeGreaterThanOrEqual(2);
expect(new Set(runs.map((r) => r.id)).size).toBe(1);
expect(maxRunning).toBe(1);
expect(runs[1]!.start).toBeGreaterThanOrEqual(runs[0]!.end!);
}, 60_000);
it("the lane's handler runs the import once while its retries find it running", async () => {
it("the lane's handler runs one import at a time while the expired run overruns", async () => {
const queue = `lane-expiry-drain-${suffix}`;
await boss.createQueue(queue, { policy: "exclusive" });
const handler = runJob(queue, drainIntegrationBackfillAdmission);
@@ -128,14 +128,16 @@ describe("an import that outlives its expiry", () => {
...EXPIRY_OVERRIDE,
});
// The run holds for eight seconds; its retries arrive while it does.
await sleep(7_000);
// The run holds for eight seconds, past its two-second expiry; the retry
// waits for it and starts after.
await sleep(12_000);
await boss.offWork(queue);
await sleep(2_000);
await sleep(8_500);
expect(deliveries.length).toBeGreaterThanOrEqual(2);
expect(runner.calls).toBe(1);
expect(new Set(deliveries).size).toBe(1);
expect(runner.maxRunning).toBe(1);
expect(runner.calls).toBe(deliveries.length);
}, 60_000);
});
@@ -145,6 +145,47 @@ async function holdLock(): Promise<Client> {
return holder;
}
/** Sessions of this database queued behind a lock, other than the holder. */
async function lockWaiters(db: Client, holderPid: number): Promise<number> {
const { rows } = await db.query<{ n: string }>(
`SELECT count(*) AS n FROM pg_stat_activity
WHERE datname = current_database()
AND wait_event_type = 'Lock'
AND pid <> $1`,
[holderPid],
);
return Number(rows[0]?.n ?? 0);
}
/**
* Let go of the held lock once a migration attempt has queued behind it and
* given up. Resolves false when no attempt queued within the budget, so the
* test reports that instead of passing on a run that never met the lock.
*/
async function releaseAfterFirstTimeout(holder: Client): Promise<boolean> {
const { rows } = await holder.query<{ pid: number }>(
"SELECT pg_backend_pid() AS pid",
);
const holderPid = rows[0].pid;
const watcher = await connect();
const until = async (predicate: (n: number) => boolean) => {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (predicate(await lockWaiters(watcher, holderPid))) return true;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return false;
};
try {
const queued = await until((n) => n > 0);
if (queued) await until((n) => n === 0);
await holder.query("ROLLBACK");
return queued;
} finally {
await watcher.end();
}
}
async function state(db: Client) {
const rows = await db.query(
`SELECT migration_name, finished_at IS NOT NULL AS finished,
@@ -181,15 +222,23 @@ describe("migration 0412 under a held lock", () => {
add0412(dir, { marked: true });
const holder = await holdLock();
// The first attempt meets the lock; the holder lets go during the backoff.
const release = setTimeout(() => {
void holder.query("ROLLBACK");
}, 2_500);
// The release follows the migration rather than a clock: it waits until
// the first attempt is seen queued behind the lock and then gone again
// (its one-second timeout fired), and lets go before the retry. A fixed
// delay raced the CLI's own start-up, which on a loaded runner outlasted
// the delay, so the first attempt found the lock already free.
const released = releaseAfterFirstTimeout(holder);
const startedAt = Date.now();
const result = await migrateDeploy(dir);
clearTimeout(release);
const releasedInTime = await released;
await holder.query("ROLLBACK").catch(() => {});
await holder.end();
expect(
releasedInTime,
"the first attempt never queued behind the held lock",
).toBe(true);
expect(result.code, result.stderr).toBe(0);
expect(result.stderr).toContain("Database error code: 55P03");
expect(result.stdout).toContain(
@@ -174,9 +174,6 @@ describe("PATCH /api/auth/me/disable-coach", () => {
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 400 for this PATCH, which answers one for a body that is not JSON",
},
});
expect(res.status).toBe(400);
expect(res.error).toBe("Invalid JSON body");
@@ -37,11 +37,6 @@ type ModulesResponse = {
updatedAt?: string;
};
const NO_400 =
"the registry declares no 400 for PATCH /api/auth/me/modules although the handler answers one for a body that is not JSON";
const NO_409 =
"the registry declares no 409 for PATCH /api/auth/me/modules although the handler answers modules_conflict for a stale base token";
beforeEach(() => truncateAllTables(prisma));
async function storedPrefs(userId: string): Promise<unknown> {
@@ -63,14 +58,12 @@ async function storedUpdatedAt(userId: string): Promise<Date> {
async function patch(
auth: Awaited<ReturnType<typeof makeSession>>,
body: Record<string, unknown>,
contract?: { skip: string },
) {
return callRoute<ModulesResponse>(PATCH, {
method: "PATCH",
route: ROUTE,
body,
auth,
contract,
});
}
@@ -229,7 +222,6 @@ describe("PATCH /api/auth/me/modules", () => {
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: { skip: NO_400 },
});
expect(res.status).toBe(400);
expect(res.error).toBe("Invalid JSON body");
@@ -281,11 +273,10 @@ describe("PATCH /api/auth/me/modules evicts the cached score", () => {
const user = await makeUser();
caches.analytics.set(`${user.id}|score`, { score: 71 });
const res = await patch(
await makeSession(user),
{ sleep: false, baseUpdatedAt: "2020-01-01T00:00:00.000Z" },
{ skip: NO_409 },
);
const res = await patch(await makeSession(user), {
sleep: false,
baseUpdatedAt: "2020-01-01T00:00:00.000Z",
});
expect(res.status).toBe(409);
expect(caches.analytics.get(`${user.id}|score`)).toEqual({ score: 71 });
});
@@ -320,11 +311,7 @@ describe("PATCH /api/auth/me/modules optimistic concurrency", () => {
const first = await patch(session, { mood: false, baseUpdatedAt: base });
expect(first.status).toBe(200);
const second = await patch(
session,
{ sleep: false, baseUpdatedAt: base },
{ skip: NO_409 },
);
const second = await patch(session, { sleep: false, baseUpdatedAt: base });
expect(second.status).toBe(409);
expect(second.meta?.errorCode).toBe("modules_conflict");
expect(await storedPrefs(user.id)).toEqual({ mood: false });
@@ -171,9 +171,6 @@ describe("PATCH /api/auth/me/unit-preference", () => {
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 400 for this PATCH although the description names it",
},
});
expect(res.status).toBe(400);
expect(await storedPreference(user.id)).toBe("metric");
@@ -199,9 +196,6 @@ describe("PATCH /api/auth/me/unit-preference", () => {
body: "unitPreference=imperial",
headers: { "content-type": "application/x-www-form-urlencoded" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 415 for this PATCH although the description names it",
},
});
expect(res.status).toBe(415);
expect(await storedPreference(user.id)).toBe("metric");
@@ -10,9 +10,6 @@
* record leaves; the static CapabilityStatement carries no user data and
* stays reachable. The module is switched the way the settings switch it,
* through the stored per-account preferences.
*
* The registry declares no 403 for these operations; the refusals below skip
* the contract check for that reason.
*/
import { beforeEach, describe, expect, it } from "vitest";
@@ -45,9 +42,6 @@ import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const INSURANCE_NUMBER = "A123456789";
const MODULE_OFF_SKIP =
"the FHIR operations declare no 403; the module gate's module.disabled envelope is undocumented on them";
type Handler = Parameters<typeof callRoute>[0];
/** Every data route, by the path a caller hits; the reads name a held id. */
@@ -135,7 +129,7 @@ async function seedOwner(doctorReport: boolean) {
return user;
}
async function callEach(auth: RouteAuth, skip: boolean) {
async function callEach(auth: RouteAuth) {
const results = [];
for (const { handler, route, params } of DATA_ROUTES) {
const res = await callRoute(handler, {
@@ -143,7 +137,6 @@ async function callEach(auth: RouteAuth, skip: boolean) {
route,
params,
auth,
contract: skip ? { skip: MODULE_OFF_SKIP } : undefined,
});
results.push({ route, res });
}
@@ -158,7 +151,7 @@ describe("/api/fhir/* and the doctorReport module", () => {
await makeToken(owner, { permissions: [FHIR_READ_SCOPE] }),
await makeSession(owner),
]) {
for (const { route, res } of await callEach(auth, true)) {
for (const { route, res } of await callEach(auth)) {
expect(res.status, route).toBe(403);
expect(res.data, route).toBeNull();
expect(res.meta, route).toMatchObject({
@@ -177,7 +170,7 @@ describe("/api/fhir/* and the doctorReport module", () => {
const owner = await seedOwner(true);
const auth = await makeToken(owner, { permissions: [FHIR_READ_SCOPE] });
for (const { route, res } of await callEach(auth, false)) {
for (const { route, res } of await callEach(auth)) {
expect(res.status, route).toBe(200);
expect(res.headers.get("content-type"), route).toContain(
"application/fhir+json",
+2 -6
View File
@@ -758,9 +758,6 @@ describe("FHIR paging", () => {
route: "/api/fhir/Observation",
query: { _offset: "1e20" },
auth,
contract: {
skip: "the registry declares no 400 on the FHIR searches, though the description names the 400 OperationOutcome",
},
});
expect(res.status).toBe(400);
@@ -784,12 +781,11 @@ describe("FHIR paging", () => {
method: "GET",
route: "/api/fhir/Patient",
auth,
contract: {
skip: "the registry declares the shared 429 as an application/json error envelope; the FHIR routes answer an application/fhir+json OperationOutcome",
},
});
expect(res.status).toBe(429);
expect(res.headers.get("content-type")).toContain("application/fhir+json");
expect(Number(res.headers.get("retry-after"))).toBeGreaterThanOrEqual(1);
expect(outcomeSchema.parse(res.body).issue[0].code).toBe("throttled");
expect(JSON.stringify(res.body)).not.toContain("Sample Patient");
});
@@ -76,15 +76,17 @@ describe("callRoute's contract check", () => {
).rejects.toThrow(/answered 409, which the contract does not declare/);
});
it("refuses a 403 that is not the generic scope refusal", async () => {
it("refuses the scope gate's 403 on an operation that declares none", async () => {
// `/api/version` takes no credential, so no 403 is declared on it; the
// generic refusal is accepted only where the contract says so.
await expect(
callRoute(
answering(403, {
data: null,
error: "nope",
meta: { errorCode: "http.forbidden" },
error: "Insufficient scope",
meta: { errorCode: "auth.scope.insufficient" },
}),
{ method: "GET", route: "/api/tokens", auth: anonymous },
{ method: "GET", route: "/api/version", auth: anonymous },
),
).rejects.toThrow(/answered 403/);
});
@@ -1124,9 +1124,6 @@ describe("every setting a person can choose", () => {
route: "/api/admin/backups/upload",
body: exported.body,
auth: operator,
contract: {
skip: "the operator's backup routes are not in the published contract",
},
});
expect(
uploaded.status,
+2 -22
View File
@@ -207,20 +207,10 @@ interface ResponseObject {
$ref?: string;
}
/**
* The generic scope gate answers 403 `auth.scope.insufficient` on any route a
* narrow Bearer token does not reach. The registry documents that refusal in
* the prose of the shared 401 (`stdResponses`) rather than as a 403 entry on
* every operation, so an operation that declares the shared 401 is taken to
* declare this 403 as well, and nothing else is.
*/
const SCOPE_REFUSAL_CODE = "auth.scope.insufficient";
function lookupResponse(
method: HttpMethod,
route: string,
status: number,
body: unknown,
): ResponseObject {
const pathItem = (openApiPaths as Record<string, unknown>)[route];
if (!isRecord(pathItem)) {
@@ -235,17 +225,7 @@ function lookupResponse(
);
}
const responses = isRecord(operation.responses) ? operation.responses : {};
let declared = responses[String(status)] ?? responses.default;
if (
!isRecord(declared) &&
status === 403 &&
isRecord(responses["401"]) &&
isRecord(body) &&
isRecord(body.meta) &&
body.meta.errorCode === SCOPE_REFUSAL_CODE
) {
declared = responses["401"];
}
const declared = responses[String(status)] ?? responses.default;
if (!isRecord(declared)) {
throw new ContractViolation(
`${method} ${route} answered ${status}, which the contract does not declare (declared: ${Object.keys(responses).join(", ")}).`,
@@ -274,7 +254,7 @@ function checkContract(
body: unknown,
isJson: boolean,
): void {
const declared = lookupResponse(method, route, status, body);
const declared = lookupResponse(method, route, status);
const content = declared.content ?? {};
const mediaTypes = Object.keys(content);
if (!isJson) {