mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'release/v1.45.0' into feat/v145-runtime
This commit is contained in:
commit
6dfb7eada6
45 files changed
+3547
-1834
No files matched your search
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1035
-929
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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.
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user