From 5a054d636e4268cdfc6a1439aab6f8b246b3702a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 19:59:11 +0200 Subject: [PATCH] docs(openapi): list every OIDC error reason the native leg can receive The login route's description named only the four reasons the login step itself sends. The callback, which the identity provider drives and so has no OpenAPI entry, ends the native leg at healthlog://oidc-callback with eight more, and nothing written listed them. An app build matched against hyphenated shorthand (identity-conflict) while the server has always sent oidc_identity_conflict, so every SSO error fell through to the generic message. The values do not change; the description now names the full set, and a structural test binds it to the literals the two routes pass to their redirect helpers in both directions. Regenerates docs/api/openapi.yaml, including the CSV import description from the previous commit. --- docs/api/openapi.yaml | 26 +++++-- ...dc-native-error-reasons-documented.test.ts | 71 +++++++++++++++++++ src/lib/openapi/routes/auth.ts | 2 +- 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/oidc-native-error-reasons-documented.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89ecc1799..423158199 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3283,7 +3283,13 @@ paths: "302": description: Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, - `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. + `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not + listed here, because the identity provider drives it rather than a client), which ends the native leg at + `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with + `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, + `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_registration_disabled`, + `oidc_failed`. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does + not know to a generic sign-in failure. /api/auth/passkey/register-options: post: tags: @@ -13063,9 +13069,12 @@ paths: before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column - is refused on any other type. An `externalId` column makes re-upload idempotent (upsert on `(userId, type, - source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without - writing. Shares the 5/hour `import:` rate bucket." + is refused on any other type. Re-uploading a file is idempotent. With an `externalId` column a row upserts on + `(userId, type, source=IMPORT, externalId)`: a changed row is `updated`, an unchanged one is `skipped` with + reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is + `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves + earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews + without writing. Shares the 5/hour `import:` rate bucket." parameters: - name: dryRun in: query @@ -35818,6 +35827,11 @@ components: anyOf: - type: string - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: string minLength: 1 @@ -36495,6 +36509,9 @@ components: minLength: 1 title: type: string + titleEncrypted: + type: string + minLength: 1 documentScoped: type: boolean summaryEncrypted: @@ -36586,7 +36603,6 @@ components: additionalProperties: {} required: - id - - title - documentScoped - createdAt - updatedAt diff --git a/src/__tests__/oidc-native-error-reasons-documented.test.ts b/src/__tests__/oidc-native-error-reasons-documented.test.ts new file mode 100644 index 000000000..b5b7ec7c9 --- /dev/null +++ b/src/__tests__/oidc-native-error-reasons-documented.test.ts @@ -0,0 +1,71 @@ +/** + * The native OIDC leg ends at `healthlog://oidc-callback?error=`, and + * the app maps each reason to its own message. The only written contract for + * that vocabulary is the login route's 302 description in the OpenAPI + * registry (the callback itself is provider-driven and has no entry). + * + * An app build once matched against hyphenated shorthand from a coordination + * note (`identity-conflict`) while the server sent `oidc_identity_conflict`, + * so every reason fell through to the generic message. This binds the two + * ends that can be read exactly: every reason either route can send on a + * redirect a native client may receive is named in that description, and + * nothing is named there that no route sends. + * + * Its limit: it reads string literals passed to the redirect helpers, so a + * reason assembled at runtime would slip past it. None is today. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, ".."); +const read = (rel: string) => readFileSync(join(ROOT, rel), "utf8"); + +/** Literal reasons passed to one of the named redirect helpers. */ +function reasons(source: string, helpers: string[]): Set { + const found = new Set(); + const pattern = new RegExp( + `\\b(?:${helpers.join("|")})\\(\\s*"(oidc_[a-z_]+)"\\s*\\)`, + "g", + ); + for (const m of source.matchAll(pattern)) found.add(m[1]); + return found; +} + +describe("native OIDC error reasons are documented", () => { + // The login route answers both legs through `loginError`; the callback's + // native-aware helper is `failRedirect`. `errorRedirect` in the callback is + // the browser-only branch taken before the state (and so the native flag) + // could be read, and never reaches the app. + const sent = new Set([ + ...reasons(read("app/api/auth/oidc/login/route.ts"), ["loginError"]), + ...reasons(read("app/api/auth/oidc/callback/route.ts"), ["failRedirect"]), + ]); + + // The login route's 302 description is one string literal on one line. + const description = + read("lib/openapi/routes/auth.ts") + .split("\n") + .find( + (line) => + line.includes("healthlog://oidc-callback?error=") && + line.includes("oidc_invalid_request"), + ) ?? ""; + const documented = new Set( + [...description.matchAll(/`(oidc_[a-z_]+)`/g)].map((m) => m[1]), + ); + + it("finds the reasons on both ends", () => { + expect(sent.size).toBeGreaterThanOrEqual(8); + expect(documented.size).toBeGreaterThanOrEqual(8); + }); + + it("documents every reason a route sends", () => { + expect([...sent].filter((r) => !documented.has(r))).toEqual([]); + }); + + it("documents no reason that no route sends", () => { + expect([...documented].filter((r) => !sent.has(r))).toEqual([]); + }); +}); diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 61f4f0743..e0791aedb 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -1908,7 +1908,7 @@ export const authPaths: NonNullable = { responses: { "302": { description: - "Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`.", + "Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_registration_disabled`, `oidc_failed`. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.", }, }, },