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.", }, }, },