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

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

No files matched your search

@@ -216,11 +216,13 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/admin/backup/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/backups/run": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups/upload": { kind: "adminConsole", methods: ["POST"] },
// `/api/admin/backups/{id}/download` and `/api/admin/backups/{id}/restore`
// are published rather than exempted: both open the encrypted stored copy,
// and the refusal they answer when it will not open is a promise an operator
// needs to be able to look up. See `src/lib/openapi/routes/admin-backups.ts`.
// `/api/admin/backups/{id}/download`, `/api/admin/backups/{id}/restore` and
// `/api/admin/backups/upload` are published rather than exempted: the first
// two open the encrypted stored copy, and the refusal they answer when it
// will not open is a promise an operator needs to be able to look up; the
// upload refuses a file for reasons (a missing key, an unknown owner, a
// newer schema) an operator has to be able to look up the same way. See
// `src/lib/openapi/routes/admin-backups.ts`.
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/central-codex": {
kind: "adminConsole",
@@ -0,0 +1,58 @@
/**
* The build fetches no font from the network.
*
* `next/font/google` downloads its files from fonts.gstatic.com while the app
* builds, and that download failing is what broke image builds. Inter is
* committed under `src/app/fonts/inter/` instead, under its licence, and
* declared through `next/font/local`. This guard keeps it that way: no source
* file imports the Google loader, every file the local declarations name is
* in the tree, and each subset carries the four weights the app was built
* with.
*
* Mutation check: importing `Inter` from the Google loader anywhere under
* `src/` fails the first case; deleting a `.woff2` or dropping a weight from a
* subset fails the second.
*/
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { readSource, readSourceFolder } from "./helpers/source-files";
const ROOT = process.cwd();
const FONT_MODULE = "src/app/fonts/inter.ts";
describe("self-hosted fonts", () => {
it("imports no font loader that downloads at build time", () => {
const importing = readSourceFolder("src", {
floor: 3000,
include: (rel) => !rel.startsWith("generated/"),
extensions: [".ts", ".tsx", ".mts", ".js", ".mjs"],
})
.filter(([, text]) => /from\s+["']next\/font\/google["']/.test(text))
.map(([rel]) => rel);
expect(importing).toEqual([]);
});
it("ships every file the declarations name, with its licence and four weights", () => {
const source = readSource(FONT_MODULE, ["next/font/local", "localFont("]);
const entries = [
...source.matchAll(/path:\s*"([^"]+)",\s*weight:\s*"(\d+)"/g),
].map(([, path, weight]) => ({ path: path!, weight: weight! }));
expect(entries.length).toBe(28);
const byFile = new Map<string, string[]>();
for (const { path, weight } of entries) {
byFile.set(path, [...(byFile.get(path) ?? []), weight]);
}
expect(byFile.size).toBe(7);
for (const [path, weights] of byFile) {
expect(existsSync(join(ROOT, dirname(FONT_MODULE), path)), path).toBe(
true,
);
expect(weights, path).toEqual(["400", "500", "600", "700"]);
}
expect(existsSync(join(ROOT, "src/app/fonts/inter/OFL.txt"))).toBe(true);
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* Inter, self-hosted. The build fetches nothing from the network for it.
*
* These are the files `next/font/google` used to download at build time for
* `Inter({ subsets: ["latin"], weight: ["400", "500", "600", "700"] })`,
* byte for byte: Google serves Inter 4.001 as one variable file per script
* subset, the same file for every weight. That download failed often enough
* to break image builds, so the files live here under their licence
* (`./inter/OFL.txt`) and the declarations below rebuild the stylesheet
* Google answered: twenty-eight faces, the four weights for each of the seven
* subsets with Google's `unicode-range`s, in Google's order (where two ranges
* overlap the face declared later wins, so the order is part of the result),
* `font-display: swap`, and only the Latin file preloaded.
*
* `next/font/local` emits one `unicode-range` per call, hence one call per
* subset, all declaring the family `Inter`. The fallback face and the
* `--font-sans` stack are in `globals.css`: the local loader would derive the
* Arial metric overrides from the font file, and they come out a little
* different from the ones next/font/google shipped, which are kept as they
* were.
*
* Imported by the root layout for its stylesheet only.
*/
import localFont from "next/font/local";
// `next/font/local` is evaluated at build time from the literal arguments of
// each call, so every option below has to be written out in place.
// The bindings are never read: importing this module is what attaches the
// faces, so each name carries the leading underscore of an intended unused.
const _interCyrillicExt = localFont({
src: [
{
path: "./inter/inter-cyrillic-ext.woff2",
weight: "400",
style: "normal",
},
{
path: "./inter/inter-cyrillic-ext.woff2",
weight: "500",
style: "normal",
},
{
path: "./inter/inter-cyrillic-ext.woff2",
weight: "600",
style: "normal",
},
{
path: "./inter/inter-cyrillic-ext.woff2",
weight: "700",
style: "normal",
},
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F",
},
],
});
const _interCyrillic = localFont({
src: [
{ path: "./inter/inter-cyrillic.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-cyrillic.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-cyrillic.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-cyrillic.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value: "U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116",
},
],
});
const _interGreekExt = localFont({
src: [
{ path: "./inter/inter-greek-ext.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-greek-ext.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-greek-ext.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-greek-ext.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{ prop: "unicode-range", value: "U+1F00-1FFF" },
],
});
const _interGreek = localFont({
src: [
{ path: "./inter/inter-greek.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-greek.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-greek.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-greek.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF",
},
],
});
const _interVietnamese = localFont({
src: [
{ path: "./inter/inter-vietnamese.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-vietnamese.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-vietnamese.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-vietnamese.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB",
},
],
});
const _interLatinExt = localFont({
src: [
{ path: "./inter/inter-latin-ext.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-latin-ext.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-latin-ext.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-latin-ext.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: false,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF",
},
],
});
const _interLatin = localFont({
src: [
{ path: "./inter/inter-latin.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-latin.woff2", weight: "500", style: "normal" },
{ path: "./inter/inter-latin.woff2", weight: "600", style: "normal" },
{ path: "./inter/inter-latin.woff2", weight: "700", style: "normal" },
],
display: "swap",
preload: true,
adjustFontFallback: false,
declarations: [
{ prop: "font-family", value: "Inter" },
{
prop: "unicode-range",
value:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD",
},
],
});
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
View File
@@ -30,6 +30,28 @@
@source not "../../.planning";
@source not "../../docs";
/*
* Inter's fallback and the font-sans stack. The Inter faces themselves come
* from `./fonts/inter.ts`. This is what next/font/google generated for Inter
* when it still fetched the font at build time: Arial, scaled with the metric
* overrides it shipped for Inter, so text keeps its box while the web font
* loads. They are written out here because next/font/local would compute its
* own, slightly different overrides from the font file.
*/
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
ascent-override: 90.44%;
descent-override: 22.52%;
line-gap-override: 0%;
size-adjust: 107.12%;
}
/* On body, where the next/font variable class used to set it. */
body {
--font-sans: "Inter", "Inter Fallback";
}
@custom-variant dark (&:is(.dark *));
/*
+4 -8
View File
@@ -1,7 +1,9 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { headers } from "next/headers";
import Script from "next/script";
// Inter's faces, self-hosted; see the module. Before globals.css, where the
// font-sans stack that names them lives, as the next/font/google import was.
import "./fonts/inter";
import "./globals.css";
import { Providers } from "@/components/providers";
import { AuthShell } from "@/components/layout/auth-shell";
@@ -12,12 +14,6 @@ import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
import { THEME_BOOT_SCRIPT, THEME_COLOR } from "@/lib/pwa/theme-color";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "HealthLog",
description:
@@ -136,7 +132,7 @@ export default async function RootLayout({
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_I18N_CATALOG_VERSION || "dev"}`}
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<body className="font-sans antialiased">
{isKeyMismatch() ? (
// The boot key check refused this process: no app shell, no data
// reads, one page that names the fix.
@@ -23,9 +23,10 @@ import { primeLocales } from "@/__tests__/helpers/prime-locales";
* REAL compiled stylesheet, lays it out in a browser at the real column
* width, and reads the boxes back.
*
* What it does NOT do is pretend to know the shipped typeface. The app loads
* Inter through `next/font/google` at build time; no font file is committed,
* so a headless browser here resolves the `font-sans` fallback chain instead.
* What it does NOT do is pretend to know the shipped typeface. The app's Inter
* faces are declared through `next/font/local` (`src/app/fonts/inter.ts`) and
* only exist in a Next build, so a headless browser here resolves the
* `font-sans` fallback chain instead.
* Every assertion below is therefore written to be font-independent — a
* relative comparison inside one run, or a structural property (does the row
* push, does the label ellipsise, is the bar still there) that holds in any
@@ -18,7 +18,7 @@ describe("pg-boss failed-row ledger retention", () => {
) as { version: string };
const plans = readFileSync(join(PG_BOSS_ROOT, "dist/plans.js"), "utf8");
expect(packageJson.version).toBe("12.37.1");
expect(packageJson.version).toBe("12.38.0");
expect(plans).toContain("retention_seconds: FORTEEN_DAYS");
expect(plans).toContain("deletion_seconds: SEVEN_DAYS");
// 12.37 reads the clock through job_now(), which is pg_catalog.now()
@@ -0,0 +1,134 @@
/**
* The generic auth gate's 403, declared on every operation that passes it.
*
* `requireAuth` answers 403 `auth.scope.insufficient` to a narrow Bearer token
* on a route that names no scope it holds, and `requireAdmin` answers 403
* `auth.admin.required` to a session that is not an administrator's. Both run
* before a route's own body, so every operation behind them can send the
* status; `withScopeRefusal` declares it on the assembled table. These checks
* hold the published document to that, and hold the helper to not trampling
* the 403s operations already publish for reasons of their own.
*/
import { describe, expect, it } from "vitest";
import { openApiPaths } from "../routes";
import {
adminRefusalResponse,
errorEnvelope,
scopeRefusalResponse,
stdResponses,
withScopeRefusal,
} from "../routes/shared";
const METHODS = ["get", "put", "post", "patch", "delete"] as const;
interface Op {
security?: unknown[];
responses?: Record<string, { description?: string } | undefined>;
}
function operations(): Array<{ path: string; method: string; op: Op }> {
const out: Array<{ path: string; method: string; op: Op }> = [];
for (const [path, item] of Object.entries(openApiPaths)) {
for (const method of METHODS) {
const op = (item as Record<string, Op | undefined>)[method];
if (op?.responses) out.push({ path, method, op });
}
}
return out;
}
const URL_CREDENTIAL_OR_OWN_GATE = new Set([
"/api/c/{token}/unlock",
"/api/notifications/action/{token}",
"/api/ingest/medication",
]);
describe("the gate's 403 in the published contract", () => {
it("is declared, naming its code, on every operation behind the gate", () => {
const behind = operations().filter(
({ path, op }) =>
op.responses?.["401"] &&
!(Array.isArray(op.security) && op.security.length === 0) &&
!URL_CREDENTIAL_OR_OWN_GATE.has(path),
);
expect(behind.length).toBeGreaterThan(500);
const silent = behind.filter(({ path, op }) => {
const code = path.startsWith("/api/admin/")
? "auth.admin.required"
: "auth.scope.insufficient";
return !op.responses?.["403"]?.description?.includes(code);
});
expect(silent.map(({ path, method }) => `${method} ${path}`)).toEqual([]);
});
it("is not declared on an operation that takes no credential", () => {
const open = operations().filter(
({ op }) => Array.isArray(op.security) && op.security.length === 0,
);
expect(open.length).toBeGreaterThan(0);
const claimed = open.filter(({ op }) =>
op.responses?.["403"]?.description?.includes("auth.scope.insufficient"),
);
expect(claimed.map(({ path, method }) => `${method} ${path}`)).toEqual([]);
});
it("keeps an operation's own 403 and its wording", () => {
const calendar = (
openApiPaths["/api/cycle/calendar"] as unknown as {
get: Op;
}
).get;
const description = calendar.responses?.["403"]?.description ?? "";
expect(description).toContain("cycle.disabled");
expect(description).toContain("auth.scope.insufficient");
});
});
describe("withScopeRefusal", () => {
const ownForbidden = {
description: "Module off. `meta.errorCode` = `module.disabled`.",
content: { "application/json": { schema: errorEnvelope } },
};
const table = withScopeRefusal({
"/api/a": {
get: { responses: { "200": {}, ...stdResponses } },
post: { responses: { "200": {}, ...stdResponses, "403": ownForbidden } },
},
"/api/b": {
get: { responses: { "200": {}, "403": ownForbidden, ...stdResponses } },
},
"/api/admin/x": { get: { responses: { "200": {}, ...stdResponses } } },
"/api/open": {
get: { security: [], responses: { "200": {}, ...stdResponses } },
},
"/api/no-auth-declared": { get: { responses: { "200": {} } } },
}) as unknown as Record<string, Record<string, Op>>;
it("adds the shared response where an operation has none", () => {
expect(table["/api/a"].get.responses?.["403"]).toBe(scopeRefusalResponse);
expect(table["/api/admin/x"].get.responses?.["403"]).toBe(
adminRefusalResponse,
);
});
it("prefixes an own 403, once per shared object, and leaves its text", () => {
const a = table["/api/a"].post.responses?.["403"];
const b = table["/api/b"].get.responses?.["403"];
expect(a).toBe(b);
expect(a?.description?.startsWith("`meta.errorCode` = `auth.scope")).toBe(
true,
);
expect(a?.description?.endsWith(ownForbidden.description)).toBe(true);
// The input object is not mutated.
expect(ownForbidden.description).not.toContain("auth.scope");
});
it("leaves an operation that takes no credential or declares no 401 alone", () => {
expect(table["/api/open"].get.responses?.["403"]).toBeUndefined();
expect(
table["/api/no-auth-declared"].get.responses?.["403"],
).toBeUndefined();
});
});
+108 -4
View File
@@ -1,13 +1,13 @@
/**
* OpenAPI route table — the backups-console routes that read a stored copy, and
* the restore job they start.
* the restore job they start, and the upload that stores a copy to restore.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
*
* Most of `/api/admin/backups/*` is deliberately unpublished: it is a
* cookie-only console, `requireAdmin()` refuses every Bearer caller, and a
* contract nobody can call against is a contract nobody reads. These two are
* here for the opposite reason. Both open the encrypted stored copy (pieces in
* contract nobody can call against is a contract nobody reads. The two readers
* are here for the opposite reason. Both open the encrypted stored copy (pieces in
* `data_backup_chunks`, or one value in `data_backups.data` for a copy written
* before v1.39.2), and both therefore have a refusal an operator meets on
* the day a key rotation went one step too far — the case
@@ -16,6 +16,11 @@
* the handler happens to answer, and a promise belongs somewhere it can be
* quoted from.
*
* The upload is here for the same reason: it refuses a file the operator has
* in hand for reasons they have to be able to look up (a key this server does
* not hold, an owner that does not exist here, a newer schema), and its answer
* is the copy id the restore takes.
*
* The restore answers 202 and runs as a background job, so the two routes that
* report on that job are published with it: a 202 without its status route is
* half a contract.
@@ -23,7 +28,12 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { dataEnvelope, errorEnvelope, idempotencyKeyParameter } from "./shared";
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
stdResponses,
} from "./shared";
const restoreRequest = z
.object({
@@ -168,7 +178,101 @@ const notFoundResponse = {
content: { "application/json": { schema: errorEnvelope } },
};
const uploadSummary = z
.object({
schemaVersion: z.string(),
userId: z.string(),
exportedAt: z.string(),
})
.catchall(z.number().int())
.meta({
id: "AdminBackupUploadSummary",
description:
"What the stored file holds: its `schemaVersion`, the account it was exported from (`userId`) and when (`exportedAt`), and one count per serialised class (`measurements`, `medications`, `intakeEvents`, `moodEntries`, …). The set of counts grows with the backup format; read the ones you know and show the rest generically.",
});
const uploadStored = z
.object({
id: z.string().meta({
description:
"`DataBackup.id` of the stored copy; pass it to `POST /api/admin/backups/{id}/restore`.",
}),
valid: z.literal(true),
summary: uploadSummary,
})
.meta({
id: "AdminBackupUploadResponse",
description:
"The file was read, checked and stored as a new copy. Nothing was restored.",
});
export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/admin/backups/upload": {
post: {
tags: ["Admin"],
summary: "Store an uploaded backup file as a new copy",
description:
"Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
requestBody: {
required: true,
content: {
"application/json": {
schema: z.record(z.string(), z.unknown()).meta({
description:
"The backup document, as `GET /api/admin/backups/{id}/download` or the account export wrote it.",
}),
},
"application/gzip": {
schema: z.string().meta({ format: "binary" }),
},
"multipart/form-data": {
schema: z.object({
file: z.string().meta({
format: "binary",
description:
"The backup file, plain or gzip-compressed, up to 10 MB.",
}),
}),
},
},
},
responses: {
"201": {
description: "The file was stored as a new copy.",
content: {
"application/json": {
schema: dataEnvelope(uploadStored, "AdminBackupUploadEnvelope"),
},
},
},
"400": {
description:
"A `multipart/form-data` body that could not be parsed as a form. Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
"401": {
description:
"No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with `meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description:
"Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = `backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description:
"The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist here.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
...stdResponses["429"],
description:
"More than three uploads in a minute by this administrator. Nothing was stored. The `Retry-After` and `X-RateLimit-*` headers describe the bucket.",
},
},
},
},
"/api/admin/backups/{id}/download": {
get: {
tags: ["Admin"],
+45
View File
@@ -15,10 +15,44 @@ import {
import {
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
recordRefusal,
stdResponses,
} from "./shared";
// The FHIR face answers its own refusals as FHIR: a 400, 404 or 429 raised
// by a `/api/fhir/*` handler is an `OperationOutcome` served as
// `application/fhir+json` (`operationOutcome` in `@/lib/fhir/rest`), which is
// what a FHIR client parses on any non-2xx. The refusals that run before the
// handler's own body are the API's: the generic 401 / 403 envelopes and the
// 403 `module.disabled`, which keeps the errorCode every client already
// branches on for a disabled module. Declared here after `...stdResponses`,
// so the FHIR shapes replace the generic JSON ones.
const fhirOutcomeContent = {
"application/fhir+json": {
schema: z.string().meta({ format: "binary" }),
},
};
const fhirDataRefusals = {
...moduleDisabledResponse,
"429": {
description:
"Rate limit exceeded: the per-account `fhir:<accountId>` bucket, 120 requests per hour, shared by every `/api/fhir/*` data route. The body is an `OperationOutcome` (`issue[0].code` = `throttled`) served as `application/fhir+json`, not the JSON error envelope. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe the bucket; back off on `Retry-After`.",
content: fhirOutcomeContent,
headers: stdResponses["429"].headers,
},
};
const fhirSearchRefusals = {
...fhirDataRefusals,
"400": {
description:
"A search parameter the server cannot honour, such as an `_offset` above 9007199254740991. The body is an `OperationOutcome` (`issue[0].code` = `invalid`, the reason in `diagnostics`) served as `application/fhir+json`.",
content: fhirOutcomeContent,
},
};
// The two request bodies this module publishes. `.meta()` CLONES in Zod 4
// rather than annotating in place, so the returned schema has to be captured
// and referenced: a bare `schema.meta({...})` statement registers nothing and
@@ -394,6 +428,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -426,6 +461,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -456,6 +492,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -486,6 +523,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -516,6 +554,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
@@ -546,6 +585,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -576,6 +616,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -606,6 +647,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -636,6 +678,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -666,6 +709,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirDataRefusals,
},
},
},
@@ -696,6 +740,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...fhirSearchRefusals,
},
},
},
+7 -2
View File
@@ -96,9 +96,9 @@ import { settingsPaths } from "./settings";
import { symptomPaths } from "./symptoms";
import { syncPaths } from "./sync";
import { workoutPaths } from "./workouts";
import { coachPrefsSchema } from "./shared";
import { coachPrefsSchema, withScopeRefusal } from "./shared";
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
const registeredPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...cyclePaths,
...metaPaths,
...healthRecordPaths,
@@ -214,6 +214,11 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...retiredPaths,
};
// The generic auth gate's 403 is declared on the assembled table, not spread
// from `stdResponses`; `scopeRefusalResponse` in `./shared` says why.
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> =
withScopeRefusal(registeredPaths);
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
// Schemas listed here are forced into `components.schemas` even when no
// route references them directly. `Medication` lives in this slot
+29 -24
View File
@@ -1141,6 +1141,29 @@ const profileSummaryResponse = z
"The bounded profile view a record viewer gets: allergies, family history and current lifestyle facts. Structurally incapable of becoming a Settings or AI surface.",
});
/**
* What the single-value `/api/auth/me/*` writes answer for a body they refuse
* to read. Each reads through `safeJson` with a 1 KB cap, so the three
* refusals are the cap's, not the route's: a wrong content type is 415, a body
* over the cap 413, and one that does not parse 400. All three are answered
* before validation, and nothing is written.
*/
const cappedScalarBodyRefusals = {
"400": {
description: "The request body is not parseable JSON. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description:
"The request did not carry `Content-Type: application/json`. Nothing was read and nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/profile/summary": {
get: {
@@ -1863,10 +1886,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -1917,10 +1937,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -1971,10 +1988,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -2257,10 +2271,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -2307,10 +2318,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -2360,10 +2368,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
+122
View File
@@ -351,6 +351,128 @@ export const stdResponses = {
},
};
// ── The generic 403 ──────────────────────────────────────────────────
/**
* The 403 the generic auth gates answer before a route's own body runs.
*
* `requireAuth` refuses a narrow Bearer token on a route that names no scope
* the token holds (`auth.scope.insufficient`), and `requireAdmin` refuses a
* session that is not an administrator's (`auth.admin.required`). The shared
* 401 described both in its prose and no operation declared the status, so a
* decoder generated from the contract had no typed branch for a refusal any
* authenticated route can send.
*
* Neither is a member of `stdResponses`, on purpose. That set is spread into
* several hundred operations, about three hundred of them declare a 403 of
* their own, and most spread the set AFTER their own entries — a shared 403
* in it would silently overwrite every module-off and sharing refusal in the
* document. `withScopeRefusal` adds them to the assembled table instead, where
* the order of a spread cannot decide it.
*/
export const scopeRefusalResponse = {
description:
"The credential is valid but its scope does not reach this operation. `meta.errorCode` = `auth.scope.insufficient`: a narrow Bearer token on a route that names no scope it holds, or a connector-bound token on a write. Retrying with the same token will not help; use a session, or a token minted with the scope the route names.",
content: { "application/json": { schema: errorEnvelope } },
};
/**
* The admin surfaces' variant. `requireAdmin` reads only the session cookie,
* so a Bearer caller never gets this far (it is a 401 there); the 403 is a
* signed-in account that is not an administrator.
*/
export const adminRefusalResponse = {
description:
"The session is valid but its account is not an administrator. `meta.errorCode` = `auth.admin.required`.",
content: { "application/json": { schema: errorEnvelope } },
};
const GATE_SENTENCE = {
scope:
"`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: ",
admin:
"`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin gate answers it before the route's own checks run. Otherwise: ",
} as const;
const GATE_CODE = {
scope: "auth.scope.insufficient",
admin: "auth.admin.required",
} as const;
/**
* Surfaces that authenticate by a credential of their own and never pass the
* generic gate, so they cannot answer its 403: a URL credential (the
* clinician share link, the notification quick answer) or the external ingest
* token, which runs its own scope check and documents its own 403.
*/
const OUTSIDE_THE_GENERIC_GATE = new Set([
"/api/c/{token}/unlock",
"/api/notifications/action/{token}",
"/api/ingest/medication",
]);
/**
* Declare the gate's 403 on every operation that passes the generic auth
* gate: one that declares a 401 and does not opt out of authentication with
* `security: []`. Under `/api/admin/` the gate is `requireAdmin`, everywhere
* else `requireAuth`. An operation without a 403 of its own gets the shared
* response; one with its own keeps it, and its description is prefixed with
* the gate's code, because a client branching on that 403 has to tell a
* module-off refusal from a gate refusal. Prefixed, not appended: the sharing
* refusal has to stay the description's closing sentence, byte for byte
* (`openapi-sharing-denial.test.ts`).
*
* Response objects shared between operations stay shared (one augmented copy
* per original), so the emitted document keeps its anchors.
*/
export function withScopeRefusal<T extends Record<string, unknown>>(
paths: T,
): T {
const augmented = {
scope: new WeakMap<object, object>(),
admin: new WeakMap<object, object>(),
};
const out: Record<string, unknown> = {};
for (const [path, item] of Object.entries(paths)) {
if (!isPlainRecord(item) || OUTSIDE_THE_GENERIC_GATE.has(path)) {
out[path] = item;
continue;
}
const gate = path.startsWith("/api/admin/") ? "admin" : "scope";
const nextItem: Record<string, unknown> = { ...item };
for (const method of ["get", "put", "post", "patch", "delete"] as const) {
const op = item[method];
if (!isPlainRecord(op) || !isPlainRecord(op.responses)) continue;
if (Array.isArray(op.security) && op.security.length === 0) continue;
if (!op.responses["401"]) continue;
const own = op.responses["403"];
let forbidden: unknown =
gate === "admin" ? adminRefusalResponse : scopeRefusalResponse;
if (isPlainRecord(own)) {
forbidden = augmented[gate].get(own);
if (!forbidden) {
const description =
typeof own.description === "string" ? own.description : "";
forbidden = description.includes(GATE_CODE[gate])
? own
: { ...own, description: GATE_SENTENCE[gate] + description };
augmented[gate].set(own, forbidden as object);
}
}
nextItem[method] = {
...op,
responses: { ...op.responses, "403": forbidden },
};
}
out[path] = nextItem;
}
return out as T;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* The 429 the shared single-record write bucket answers with.
*
@@ -0,0 +1,140 @@
/**
* What Strava's token endpoint answers when it refuses, and what the sync
* makes of it.
*
* Strava does not answer RFC 6749 errors. A refused refresh is the API's
* generic `Fault`; the bodies below are the ones the endpoint sends, as
* captured from live traffic by several unrelated client libraries (Strava
* documents the `Fault` / `Error` model, not these particular values). The
* HTTP layer is the only thing replaced: `postToken`, the error it throws and
* the classifier all run as they do in production.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() }));
import { safeFetch } from "@/lib/safe-fetch";
import { exchangeCode, refreshAccessToken } from "../client";
import {
classifyStravaError,
stravaFaultSummary,
StravaApiError,
} from "../response-classifier";
import { classifyStravaFailure } from "../sync";
const creds = { clientId: "12345", clientSecret: "not-a-real-secret" };
const DEAD_REFRESH_TOKEN = {
message: "Bad Request",
errors: [
{ resource: "RefreshToken", field: "refresh_token", code: "invalid" },
],
};
const BAD_CLIENT_ID = {
message: "Bad Request",
errors: [{ resource: "Application", field: "client_id", code: "invalid" }],
};
const BAD_CLIENT = {
message: "Authorization Error",
errors: [{ resource: "Application", field: "", code: "invalid" }],
};
function answer(status: number, body: unknown) {
vi.mocked(safeFetch).mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
);
}
async function refusal(run: () => Promise<unknown>): Promise<StravaApiError> {
const err = await run().then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(StravaApiError);
return err as StravaApiError;
}
beforeEach(() => {
vi.mocked(safeFetch).mockReset();
});
describe("a refused Strava token request", () => {
it("asks for a reconnect when the refresh token is dead", async () => {
answer(400, DEAD_REFRESH_TOKEN);
const err = await refusal(() => refreshAccessToken("rt-dead", creds));
expect(err.httpStatus).toBe(400);
expect(err.upstreamError).toBe("RefreshToken.refresh_token:invalid");
expect(classifyStravaError(err)).toBe("reauth_required");
expect(classifyStravaFailure(err)).toBe("reauth_required");
});
it("still asks for a reconnect when the error lost its prototype on a retry", async () => {
answer(400, DEAD_REFRESH_TOKEN);
const err = await refusal(() => refreshAccessToken("rt-dead", creds));
const replayed = new Error(err.message);
expect(replayed.message).toBe(
"Strava refreshAccessToken error: 400 - RefreshToken.refresh_token:invalid",
);
expect(classifyStravaError(replayed)).toBe("reauth_required");
});
it("leaves a fault that names the application to the status, as before", async () => {
// A client id or secret the instance holds wrongly. Whether Strava ever
// answers a per-person condition this way is not documented, so the
// status decides, as it always has: 400 persistent, 401 reconnect.
answer(400, BAD_CLIENT_ID);
const bad = await refusal(() => refreshAccessToken("rt-live", creds));
expect(bad.upstreamError).toBe("Application.client_id:invalid");
expect(classifyStravaError(bad)).toBe("persistent");
answer(401, BAD_CLIENT);
const unauthorised = await refusal(() =>
refreshAccessToken("rt-live", creds),
);
expect(unauthorised.upstreamError).toBe("Application:invalid");
expect(classifyStravaError(unauthorised)).toBe("reauth_required");
});
it("leaves a 400 that names neither as persistent and a 5xx as transient", async () => {
answer(400, { message: "Bad Request", errors: [] });
const plain = await refusal(() => exchangeCode("code", creds));
expect(plain.upstreamError).toBe("Bad Request");
expect(classifyStravaError(plain)).toBe("persistent");
answer(503, { message: "Service Unavailable" });
const outage = await refusal(() => refreshAccessToken("rt-live", creds));
expect(classifyStravaError(outage)).toBe("transient");
});
it("still honours a standards-shaped invalid_grant", async () => {
answer(400, { error: "invalid_grant" });
const err = await refusal(() => refreshAccessToken("rt-dead", creds));
expect(err.upstreamError).toBe("invalid_grant");
expect(classifyStravaError(err)).toBe("reauth_required");
});
});
describe("stravaFaultSummary", () => {
it("names only the resource, field and code, and drops anything else", () => {
expect(
stravaFaultSummary({
message: "Bad Request",
errors: [
{
resource: "RefreshToken",
field: "refresh_token<script>",
code: "invalid",
},
],
}),
).toBe("RefreshToken:invalid");
expect(stravaFaultSummary(null)).toBeUndefined();
expect(stravaFaultSummary({ errors: "nope" })).toBeUndefined();
});
});
+8 -3
View File
@@ -25,7 +25,11 @@ import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { parseRetryAfterMs } from "@/lib/integrations/oauth-kit/retry-after";
import type { Prisma } from "@/generated/prisma/client";
import { StravaApiError, classifyStravaResponse } from "./response-classifier";
import {
StravaApiError,
classifyStravaResponse,
stravaFaultSummary,
} from "./response-classifier";
import { mapStravaSportType } from "./sport-map";
import type { WorkoutSportType } from "@/lib/validations/workout";
import { envOr, envValue } from "@/lib/env";
@@ -128,8 +132,9 @@ async function postToken(
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError:
typeof json?.message === "string" ? json.message : undefined,
// Strava's `Fault`, reduced to the resource it names; the classifier
// tells a dead refresh token from a misconfigured app by it.
upstreamError: stravaFaultSummary(json),
});
}
return json as StravaTokenResponse;
+66 -9
View File
@@ -7,8 +7,9 @@
* - `success` → HTTP 2xx
* - `transient` → 429 (rate-limited — respect X-RateLimit headers),
* 5xx, 3xx, unknown.
* - `reauth_required` → 401 / 403 — token rejected / grant revoked, plus an
* `invalid_grant` on the refresh endpoint.
* - `reauth_required` → 401 / 403 — token rejected / grant revoked, plus a
* dead refresh token on the token endpoint (see
* `isStravaDeadRefreshToken`).
* - `persistent` → any other 4xx — malformed request / contract drift.
*/
@@ -55,16 +56,72 @@ export function isStravaRateLimited(err: unknown): err is StravaApiError {
}
/**
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* the canonical revoked / rotated-away refresh-token signal. Lifts a 400
* carrying `invalid_grant` from `persistent` to `reauth_required` so the
* connection surfaces the reconnect prompt.
* Strava's token endpoint does not answer in RFC 6749 form. A refusal is the
* API's generic `Fault` (developers.strava.com/docs/reference, "Fault" and
* "Error"): `{"message":"Bad Request","errors":[{"resource":"RefreshToken",
* "field":"refresh_token","code":"invalid"}]}`, with no `error` member at all,
* so an `invalid_grant` check never matches it. This reduces the fault to one
* stable token for the error message — `<resource>.<field>:<code>` of the first
* entry, e.g. `RefreshToken.refresh_token:invalid` or `Application:invalid` —
* and falls back to an RFC 6749 `error` code, then to `message`. Nothing of
* the body beyond those names reaches the message.
*/
function isInvalidGrant(err: unknown): boolean {
return isOAuthInvalidGrant(err, "Strava");
export function stravaFaultSummary(body: unknown): string | undefined {
if (typeof body !== "object" || body === null) return undefined;
const fault = body as {
message?: unknown;
errors?: unknown;
error?: unknown;
};
// A standards-shaped body (`{"error":"invalid_grant"}`) keeps its code.
if (typeof fault.error === "string" && /^[a-z_]{1,40}$/.test(fault.error)) {
return fault.error;
}
const first = Array.isArray(fault.errors) ? fault.errors[0] : undefined;
if (typeof first === "object" && first !== null) {
const { resource, field, code } = first as Record<string, unknown>;
if (typeof resource === "string" && /^[A-Za-z]{1,40}$/.test(resource)) {
const at =
typeof field === "string" && /^[A-Za-z_]{1,40}$/.test(field)
? `.${field}`
: "";
const why =
typeof code === "string" && /^[A-Za-z_]{1,40}$/.test(code)
? `:${code}`
: "";
return `${resource}${at}${why}`;
}
}
return typeof fault.message === "string"
? fault.message.slice(0, 120)
: undefined;
}
/**
* True for a refresh token Strava no longer honours: a 400 whose fault names
* the `RefreshToken` resource. That is what the token endpoint answers once the
* athlete revoked the app or the token was rotated away, and the only way back
* is a new authorisation, so it lifts the verdict to `reauth_required`. A
* standards-shaped `invalid_grant` is accepted too, in case Strava ever sends
* one.
*
* Robust to a lost prototype across a pg-boss retry, like
* `isOAuthInvalidGrant`: it falls back to the
* `"Strava <verb> error: 400 - RefreshToken…"` message shape.
*/
export function isStravaDeadRefreshToken(err: unknown): boolean {
if (isOAuthInvalidGrant(err, "Strava")) return true;
if (err instanceof IntegrationApiError) {
return (
err.httpStatus === 400 &&
(err.upstreamError?.startsWith("RefreshToken") ?? false)
);
}
const msg = err instanceof Error ? err.message : String(err);
return /Strava\s+\w+\s+error:\s*400\s+-\s+RefreshToken\b/.test(msg);
}
export function classifyStravaError(err: unknown): IntegrationClassification {
if (isInvalidGrant(err)) return "reauth_required";
if (isStravaDeadRefreshToken(err)) return "reauth_required";
return classifyIntegrationError(err, "Strava");
}
+3 -2
View File
@@ -15,8 +15,9 @@
* Token model: Strava ROTATES its refresh token on every refresh. The schema
* has no expiry column, so the sync refreshes REACTIVELY — the first read that
* 401s triggers one refresh (persisting BOTH rotated tokens via a compare-and-
* set) and a single retry. A failed refresh (`invalid_grant`) records
* `reauth_required` on the `strava` ledger.
* set) and a single retry. A refresh Strava refuses for a dead token (a 400
* whose fault names `RefreshToken`) records `reauth_required` on the `strava`
* ledger; any other refusal is classified by its status.
*
* Rate limits: 200 req / 15 min + 2000 / day per app. The walk is page-bounded
* (`walkPages`, which reports a walk cut off at its ceiling instead of