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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 19:59:11 +02:00
1 parent 24050d84c7
commit 5a054d636e
3 files changed
+93 -6

No files matched your search

+21 -5
View File
@@ -3283,7 +3283,13 @@ paths:
"302":
description: Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or
`healthlog://oidc-callback?error=<reason>` (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=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with
`error=<reason>`, 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
@@ -0,0 +1,71 @@
/**
* The native OIDC leg ends at `healthlog://oidc-callback?error=<reason>`, 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<string> {
const found = new Set<string>();
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=<reason>") &&
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([]);
});
});
+1 -1
View File
@@ -1908,7 +1908,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
responses: {
"302": {
description:
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (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=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (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=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, 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.",
},
},
},