mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user