From 06f619120d0fb4b4bc4f4965862d4ad19e6753a8 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Mon, 25 May 2026 14:42:24 +1000 Subject: [PATCH] =?UTF-8?q?feat+test+docs:=20D46=20=E2=80=94=20owner-vs-gu?= =?UTF-8?q?est=20gating=20for=20/health=20+=20X-OLP-Fallback-Detail=20(Pha?= =?UTF-8?q?se=202)=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat+test+docs: D46 — owner-vs-guest gating for /health + X-OLP-Fallback-Detail (Phase 2) Third Phase 2 implementation D-day. Closes ADR 0007 § 10 acceptance criteria #4 (/health payload trimming for non-owner) + #5 (X-OLP-Fallback-Detail emission gating per fallback_detail_header_policy). Phase 2 server surface now fully gated end-to-end; remaining D-days are keygen CLI surface (D47+) and Phase 2 close (v0.2.0, maintainer- triggered). server.mjs handleHealth identity-aware payload per § 7.1: - Auth gate at top — 401 for unauth + allow_anonymous=false; 200 with trimmed { ok, version } for non-owner; 200 with full payload for owner. - Trim controlled by _authConfig.owner_only_endpoints — operator removing /health from the list reverts to v0.1.1 full-payload-to- everyone (opt-out knob). - touchLastUsed fires on res.on('finish') for filesystem identities; no audit row on /health (high-volume monitoring; out of scope at Phase 2 per § 8). server.mjs withFallbackDetailHeader identity-aware emission per § 7.2: - New shouldEmitFallbackDetailHeader(olpIdentity) helper reads _authConfig.fallback_detail_header_policy: 'owner_only' (default) → emit only to owner 'all' → emit unconditionally (v0.1.1 opt-back-in) 'none' → suppress unconditionally - olpIdentity null on pre-auth paths → emit (preserves D40 v0.1.1 behaviour for pre-auth errors where identity is unknown). - withFallbackDetailHeader signature gains 3rd `olpIdentity` arg; both call sites in handleChatCompletions updated. Test surface — Suite 21, +9 tests; +1 in Suite 20 (20m); 515 → 524: 20m: /health with no auth + allow_anonymous=false → 401 (consistency with /v1/*) 21a-d: /health payload trimming (criterion #4): anonymous trimmed; guest trimmed; owner full; owner_only_endpoints: [] opts out 21e-h: X-OLP-Fallback-Detail emission gating (criterion #5): owner_only + guest → header absent owner_only + owner → header present + valid JSON 'all' + guest → header present (v0.1.1 opt-back) 'none' + owner → header absent (full suppression) Tests use 2-hop chain anthropic→openai with anthropic primary failing to produce non-empty fallbackDetail for header content. Test-mode setup updated: Global __setAuthConfig({ allow_anonymous: true }) extended to also pass owner_only_endpoints: [] + fallback_detail_header_policy: 'all' so pre-D46 tests (Suite 18, F5 /health tests, D40 fallback-detail tests, etc.) continue to pass; Suite 21 overrides per-case. DOCS: - AGENTS.md: lib/keys.mjs marker updated to reflect D46 ship; impl- status-note + shipped-set updated. - README.md: Implementation Status row + Known limitations "Multi-key auth" note rewritten to reflect D46 ship + remaining keygen CLI. - CHANGELOG.md: D46 entry under Unreleased per release_kit overlay. AUTHORITY: - ADR 0007 §§ 7.1 + 7.2 implementation contracts + § 10 criteria #4 + #5 covered. - ADR 0004 Amendment 5 (D40 — "Phase 2 will re-introduce owner-vs- non-owner gating when lib/keys.mjs lands"): this D-day fulfils the deferral. - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. - Standing autopilot grant (~/.cc-rules/memory/auto/ standing_autopilot_phase_2.md in cc-rules bf0ed9a). Verified: 524/524 pass via npm test (no regression in 515 pre-D46 tests; 9 new Suite 21 tests + 1 new Suite 20m test all green). Co-Authored-By: Claude Opus 4.7 * docs: D46 fold-in — opus reviewer P3 polish (constant import + comment tighten) Fresh-context opus reviewer (PR #22) returned APPROVE_WITH_MINOR with 2 P3 findings, both trivial polish. - server.mjs imports gain ENV_OWNER_KEY_ID from lib/keys.mjs (already used the namesake ANONYMOUS_KEY_ID import). handleHealth touchLastUsed guard now uses the imported constant for SPOT discipline. - handleHealth audit-deferral comment tightened: removed the "§ 8 schema doesn't mandate auditing" phrasing (overstates the ADR — § 8 doesn't enumerate paths); replaced with the operational rationale (high-volume noise, no observability value until Phase 3 Dashboard). No behaviour change. 524/524 tests pass (verified locally). Authority: PR #22 fresh-context opus reviewer findings. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: dtzp555 Co-authored-by: Claude Opus 4.7 --- AGENTS.md | 4 +- CHANGELOG.md | 24 ++++ README.md | 4 +- server.mjs | 93 ++++++++++++-- test-features.mjs | 309 ++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 412 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c51f8e..98698a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj - `lib/ir/` — Intermediate Representation definition + serializers. Governed by ADR 0003. - `lib/cache/` — content-addressed cache layer (per-key isolation, `cache_control` bypass, chunked stream replay, singleflight). Governed by ADR 0005. - `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004. -- `lib/keys.mjs` — multi-key auth, per-key namespacing, identity layer. Carries OCP's per-key isolation model into OLP. **🟡 Phase 2 — D44 core + D45 server integration shipped (validateKey is invoked on every /v1/* request; chain filtered by providers_enabled; touchLastUsed fires post-response). Remaining: D46 owner-vs-guest /health + X-OLP-Fallback-Detail gating; keygen CLI bootstrap surface (D46+).** +- `lib/keys.mjs` — multi-key auth, per-key namespacing, identity layer. Carries OCP's per-key isolation model into OLP. **🟡 Phase 2 — D44 core + D45 server integration + D46 owner gating shipped (validateKey on every /v1/* + /health; chain filtered by providers_enabled; touchLastUsed fires post-response; /health payload trimmed for non-owner; X-OLP-Fallback-Detail gated by fallback_detail_header_policy). Remaining: keygen CLI bootstrap surface; Phase 2 close → v0.2.0.** - `lib/audit.mjs` — append-only ndjson audit per ADR 0007 § 6.2 + § 8. **🟡 D45 — appendAuditEvent + getAuditDropCount shipped. Fires per /v1/chat/completions + /v1/models request including 401/403/5xx paths. Warn+1-retry on append failure; no memory buffer at Phase 2 (forward path).** - `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — not yet authored.** - `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT. @@ -46,7 +46,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj - `.github/workflows/alignment.yml` — CI blacklist grep + per-provider citation soft check; fails the build on known-hallucinated tokens. - `CLAUDE.md` — Claude-Code-specific session instructions + `release_kit` overlay (Iron Rule 5.5). -**Implementation status note (as of 2026-05-25):** Files marked 📋 above are designed and documented but not yet on disk; files marked 🟡 are partially shipped (specific components landed; others scheduled). For the full status table see `README.md § "Implementation status"`. The shipped set as of D45 is: `server.mjs` (with Phase 2 auth middleware + audit wire), `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `lib/keys.mjs` (core + loadAuthConfigSync — D44 + D45), `lib/audit.mjs` (D45), `models-registry.json`, `test-features.mjs` (Suites 19 + 20). Phase 2 in progress: D46 owner gating for /health + X-OLP-Fallback-Detail; keygen CLI bootstrap surface; Phase 2 close → v0.2.0. +**Implementation status note (as of 2026-05-25):** Files marked 📋 above are designed and documented but not yet on disk; files marked 🟡 are partially shipped (specific components landed; others scheduled). For the full status table see `README.md § "Implementation status"`. The shipped set as of D46 is: `server.mjs` (with Phase 2 auth middleware + audit wire + owner-vs-non-owner gating), `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `lib/keys.mjs` (core + loadAuthConfigSync — D44 + D45), `lib/audit.mjs` (D45), `models-registry.json`, `test-features.mjs` (Suites 19 + 20 + 21). Phase 2 in progress: keygen CLI bootstrap surface; Phase 2 close → v0.2.0 (maintainer-triggered). --- diff --git a/CHANGELOG.md b/CHANGELOG.md index c311825..5260148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this ## Unreleased +### D46 — owner-vs-guest gating for `/health` + `X-OLP-Fallback-Detail` (Phase 2 closes header observability gap) + +Third Phase 2 implementation D-day. Closes ADR 0007 § 10 acceptance criteria #4 (`/health` payload trimming for non-owner) + #5 (`X-OLP-Fallback-Detail` emission gating per `fallback_detail_header_policy`). Phase 2 server surface is now fully gated end-to-end; remaining D-days are keygen CLI surface (D47+) and Phase 2 close (v0.2.0, maintainer-triggered). + +- **`server.mjs` `handleHealth` identity-aware payload** per ADR § 7.1: + - Auth gate at top — `authenticate(req)` returns 401 for unauth + `allow_anonymous: false` (consistent with /v1/* routes); 200 with trimmed payload for anonymous / guest; 200 with full payload for owner. + - Trim controlled by `_authConfig.owner_only_endpoints` — if `/health` is in the list, non-owner gets `{ ok: true, version }`; else (operator removes it) full payload to everyone (v0.1.1 opt-out knob). + - `touchLastUsed` fired on `res.on('finish')` for filesystem identities (matches /v1/* pattern). No audit row on /health — high-volume monitoring endpoint, audit volume noise not justified at Phase 2 (would land with Phase 3 Dashboard if aggregate /health stats become needed). +- **`server.mjs` `withFallbackDetailHeader` identity-aware emission** per ADR § 7.2: + - New helper `shouldEmitFallbackDetailHeader(olpIdentity)` reads `_authConfig.fallback_detail_header_policy`: + - `'owner_only'` (default) → emit only when `olpIdentity.owner_tier === 'owner'` + - `'all'` → emit unconditionally (v0.1.1 opt-back-in for operators who want the diagnostic header for all identities) + - `'none'` → suppress unconditionally + - When `olpIdentity` is null (pre-auth error paths), defaults to emit — preserves the v0.1.1 ungated behaviour for pre-auth errors where identity is unknown. + - `withFallbackDetailHeader` signature gains a third `olpIdentity` argument; both call sites in `handleChatCompletions` updated to pass `olpIdentity`. +- **Test surface (Suite 21, +9 tests + 1 added in Suite 20 — 515 → 524):** + - **20m** /health with no auth + `allow_anonymous=false` → 401 (consistency with /v1/* routes) + - **21a-d** /health payload trimming (criterion #4): anonymous trimmed; guest trimmed; owner full; `owner_only_endpoints: []` opts out (guest gets full) + - **21e-h** X-OLP-Fallback-Detail emission gating (criterion #5): `owner_only` + guest → header absent; `owner_only` + owner → header present + valid JSON; `'all'` + guest → header present (v0.1.1 opt-back); `'none'` + owner → header absent (full suppression). Tests use a 2-hop chain (anthropic primary fail + openai secondary) to produce non-empty `fallbackDetail` for the header content. +- **Test-mode setup updated:** the global `__setAuthConfig({ allow_anonymous: true })` was extended to also pass `owner_only_endpoints: []` + `fallback_detail_header_policy: 'all'` so pre-D46 tests (Suite 18, F5 /health tests, D40 fallback-detail tests, etc.) continue to pass without modification — Suite 21 explicitly overrides per-case to exercise the production-default-gated paths. +- **Documentation:** AGENTS.md `lib/keys.mjs` marker updated to reflect D46 ship; Implementation-status-note updated. README.md Implementation Status row + Known limitations "Multi-key auth" note rewritten to reflect D46 ship + remaining keygen CLI. +- **Test count:** 515 → 524 (+9 D46 tests). +- **Authority:** ADR 0007 (multi-key auth — §§ 7.1 + 7.2 implementation contracts + § 10 acceptance criteria #4 + #5 covered); ADR 0004 Amendment 5 (D40 ratification of "Phase 2 will re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands" — this D-day fulfils the deferral); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a`). + ### D45 — `server.mjs` auth integration + `lib/audit.mjs` (Phase 2 wire-up) Second Phase 2 implementation D-day. Wires the D44 `lib/keys.mjs` identity layer into the request flow + lands `lib/audit.mjs` per ADR 0007 § 6.2 + § 8. Closes acceptance criteria #1 (per-key cache isolation, validation-side end-to-end), #2 (anonymous prod-default off), #3 (anonymous dev-mode on), #6 (post-revoke 401 within next request — full), #8 (audit ndjson round-trip), #10 (`OLP_OWNER_TOKEN` env override — full server-side), #11 (`providers_enabled` 403 scope). Owner-vs-guest gating for `/health` + `X-OLP-Fallback-Detail` (criteria #4, #5) remains in D46 scope. diff --git a/README.md b/README.md index 64ac346..04d48ef 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Phase 1 is closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). P | Soft trigger data path (`quotaStatus()` polling) | 📋 Planned (v1.x) | Evaluation logic shipped + tested; data ingestion deferred per ADR 0004 Amendment 2 | | `models-registry.json` | ✅ Shipped | SPOT for `(provider, model)` metadata | | `test-features.mjs` | ✅ Shipped | Comprehensive test suite covering IR, cache, fallback, and integration paths (CI: `test.yml`) | -| `lib/keys.mjs` | 🟡 Phase 2 — core + server integration (D44 + D45) | Multi-key auth core (`createKey` / `validateKey` / `listKeys` / `revokeKey` / `touchLastUsed`) per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4 + `loadAuthConfigSync` for `auth.allow_anonymous` (default false). Server wires `validateKey` per request, filters chain by `providers_enabled`, fires `touchLastUsed` post-response. D46 will add owner-vs-guest gating for `/health` + `X-OLP-Fallback-Detail`. | +| `lib/keys.mjs` | 🟡 Phase 2 — core + server integration + owner gating (D44 + D45 + D46) | Multi-key auth core (`createKey` / `validateKey` / `listKeys` / `revokeKey` / `touchLastUsed`) per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4 + `loadAuthConfigSync` for `auth.allow_anonymous` / `owner_only_endpoints` / `fallback_detail_header_policy` (default `false` / `['/health']` / `'owner_only'`). Server wires `validateKey` per request, filters chain by `providers_enabled`, fires `touchLastUsed` post-response, trims `/health` payload for non-owner, gates `X-OLP-Fallback-Detail` emission by policy. Remaining: keygen CLI bootstrap surface; Phase 2 close → v0.2.0. | | `lib/audit.mjs` | 🟡 Phase 2 — shipped at D45 | Append-only ndjson audit at `~/.olp/logs/audit.ndjson` per ADR 0007 § 6.2 + § 8. `appendAuditEvent` fires for every `/v1/*` request (success, 401, 403, 5xx). Warn + 1 retry on append failure; no memory buffer at Phase 2 (forward path). PII excluded (no message / response content). `OLP_HOME` env override supported for test/operator isolation. | | `dashboard.html` | 📋 Planned (Phase 6) | Owner-only multi-provider dashboard | | `docs/provider-caveats.md` | 📋 Planned (Phase 3+) | Lossy-translation reference; for now documented inline in each plugin header | @@ -197,7 +197,7 @@ Behaviors that work correctly at personal/family scale but have ratified follow- - **Streaming-path singleflight not implemented.** The cache layer's D4 singleflight (one spawn per identical concurrent request) is fully wired on the buffered path but NOT on the streaming path. N concurrent identical streaming requests at v0.1 will each spawn their own CLI process. Design ratified in [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md); implementation tracked via [issue #16](https://github.com/dtzp555-max/olp/issues/16) and [v1.x roadmap #1](./docs/v1x-roadmap.md). At family scale this is observably fine — every caller still receives the correct response; the cost is N CLI processes instead of one. - **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible. -- **Multi-key auth shipped through server integration (D45); owner gating pending D46.** `lib/keys.mjs` core (createKey / validateKey / listKeys / revokeKey / touchLastUsed) shipped at D44; `server.mjs` now invokes `validateKey` per request, filters the chain by `providers_enabled`, fires `touchLastUsed` post-response, and appends an audit row to `~/.olp/logs/audit.ndjson` for each `/v1/*` request. The owner-vs-guest gating for `/health` payload trimming and `X-OLP-Fallback-Detail` header suppression is scheduled D46. Phase 2 in progress through D46 (owner gating), D47+ (keygen CLI surface, docs), Phase 2 close → v0.2.0. Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md). +- **Multi-key auth + owner gating shipped (D44 + D45 + D46); keygen CLI pending.** `lib/keys.mjs` core shipped at D44; `server.mjs` invokes `validateKey` per request, filters the chain by `providers_enabled`, fires `touchLastUsed` post-response, and appends an audit row to `~/.olp/logs/audit.ndjson` for each `/v1/*` request (D45). At D46: `/health` payload trimmed for non-owner (returns `{ ok, version }` when `owner_only_endpoints` includes `/health`); `X-OLP-Fallback-Detail` emission gated by `fallback_detail_header_policy` (`owner_only` default suppresses for non-owner; `'all'` opts back into v0.1.1 behaviour; `'none'` suppresses entirely). Phase 2 in progress through D47+ (keygen CLI surface, docs), Phase 2 close → v0.2.0 (maintainer-triggered). Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md). **New env vars consumed at D45:** `OLP_HOME` (override `~/.olp/` location, used by tests + operator deployments); `OLP_OWNER_TOKEN` (synthetic env-owner identity for headless / CI deployments — bypasses filesystem manifest lookup with stable `__env_owner__` keyId). diff --git a/server.mjs b/server.mjs index 73abc84..62949be 100644 --- a/server.mjs +++ b/server.mjs @@ -54,6 +54,7 @@ import { touchLastUsed, loadAuthConfigSync, ANONYMOUS_KEY_ID, + ENV_OWNER_KEY_ID, } from './lib/keys.mjs'; import { appendAuditEvent } from './lib/audit.mjs'; @@ -393,16 +394,42 @@ function jsonStringifyAscii(value) { } /** - * Merges X-OLP-Fallback-Detail into a base header object when the per-hop - * failure tuples are non-empty. Returns the base object unchanged otherwise. + * Identity-aware gate for X-OLP-Fallback-Detail emission per ADR 0007 § 7.2. + * Reads `_authConfig.fallback_detail_header_policy`: + * - 'owner_only' (default) → emit only when olpIdentity.owner_tier === 'owner' + * - 'all' → emit unconditionally (v0.1.1 behaviour, opt-in) + * - 'none' → suppress unconditionally + * When olpIdentity is null (early-error paths before auth completed), + * defaults to 'all' → emit (preserves the v0.1.1 ungated behaviour for + * pre-auth errors where we don't yet know identity). * - * D40 (issue #7). + * @param {{owner_tier?: string}|null|undefined} olpIdentity + * @returns {boolean} + */ +function shouldEmitFallbackDetailHeader(olpIdentity) { + const policy = _authConfig?.fallback_detail_header_policy ?? 'owner_only'; + if (policy === 'none') return false; + if (policy === 'all') return true; + // 'owner_only' — gate by identity tier + if (!olpIdentity) return true; // pre-auth path: don't suppress diagnostic info + return olpIdentity.owner_tier === 'owner'; +} + +/** + * Merges X-OLP-Fallback-Detail into a base header object when the per-hop + * failure tuples are non-empty AND the per-request identity is permitted + * to see the header per the policy (ADR 0007 § 7.2). Returns the base + * object unchanged otherwise. + * + * D40 (issue #7) — gating added at D46 per ADR 0004 Amendment 5 ratification. * * @param {Record} baseHeaders * @param {Array|null|undefined} fallbackDetail + * @param {{owner_tier?: string}|null|undefined} olpIdentity - request identity; null on pre-auth paths * @returns {Record} */ -function withFallbackDetailHeader(baseHeaders, fallbackDetail) { +function withFallbackDetailHeader(baseHeaders, fallbackDetail, olpIdentity) { + if (!shouldEmitFallbackDetailHeader(olpIdentity)) return baseHeaders; const value = serializeFallbackDetailHeader(fallbackDetail); if (value === null) return baseHeaders; return { ...baseHeaders, 'X-OLP-Fallback-Detail': value }; @@ -486,14 +513,59 @@ function isProviderEnabled(authContext, providerKey) { /** * GET /health - * Returns server health including count of loaded providers and per-provider - * healthCheck() snapshots (ADR 0002 § Provider contract: healthCheck is used - * by startup and /health endpoint per ADR 0002 § Provider contract description). * - * Phase 2 / D45: no auth gate at this endpoint yet. Owner-vs-guest /health - * payload trimming per ADR 0007 § 7.1 lands at D46. + * Phase 2 / D46 (ADR 0007 § 7.1): identity-aware payload. + * - owner identity → full per-provider details (existing payload) + * - guest / anonymous → trimmed { ok, version } only + * - no auth, allow_anonymous=false → 401 (consistent with /v1/* routes) + * + * The trimming behavior is gated on `_authConfig.owner_only_endpoints` — + * the entry `/health` lives there by default. Removing `/health` from + * the list reverts to v0.1.1 full-payload-to-everyone behaviour. + * + * Authority: ADR 0007 § 7.1 (Identity-class table) + § 7.2 (owner_only_endpoints). + * Closes acceptance criterion #4. */ async function handleHealth(req, res) { + const startMs = Date.now(); + + // D46: audit on /health is intentionally NOT enabled at Phase 2. + // /health is a high-volume monitoring endpoint; per-call audit rows would + // generate operational noise that has no observability value until a + // Phase 3+ Dashboard aggregates /health stats. Deferred to Phase 3. + + const authResult = authenticate(req); + if (!authResult.ok) { + return sendError(res, authResult.status, authResult.message, authResult.code); + } + const olpIdentity = authResult.authContext; + + // Per § 7.1: owner sees full payload; guest + anonymous see trimmed. + // Per § 7.2: gating is opt-out via `owner_only_endpoints` config; if + // `/health` is removed from the list, all identities see the full + // payload (operators wanting v0.1.1 behaviour have this knob). + const gatedEndpoints = Array.isArray(_authConfig?.owner_only_endpoints) + ? _authConfig.owner_only_endpoints + : ['/health']; + const isGated = gatedEndpoints.includes('/health'); + const isOwner = olpIdentity.owner_tier === 'owner'; + + // Touch last_used_at for filesystem identities post-response. The callee + // also early-returns on ANONYMOUS / ENV_OWNER keyIds (lib/keys.mjs § 6.3 + // wrapper) — this guard is defense-in-depth + skip the async call entirely + // for non-filesystem identities. + if (olpIdentity.keyId !== ANONYMOUS_KEY_ID && olpIdentity.keyId !== ENV_OWNER_KEY_ID) { + res.on('finish', () => { + touchLastUsed(olpIdentity.keyId).catch(() => {}); + }); + } + + if (isGated && !isOwner) { + // Trimmed payload per § 7.1. + return sendJSON(res, 200, { ok: true, version: VERSION }); + } + + // Full payload (owner OR /health removed from owner_only_endpoints). const enabled = loadedProviders.size; const available = listAllProviderNames().length; const providerStatuses = {}; @@ -1323,7 +1395,7 @@ async function handleChatCompletions(req, res) { type: 'provider_error', }, }); - const detailHeader = withFallbackDetailHeader({}, fallbackDetail); + const detailHeader = withFallbackDetailHeader({}, fallbackDetail, olpIdentity); res.writeHead(errStatus, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), @@ -1355,6 +1427,7 @@ async function handleChatCompletions(req, res) { const headers = withFallbackDetailHeader( olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }), fallbackDetail, + olpIdentity, ); // Audit ctx capture for success path (audit fires on res.on('finish'); diff --git a/test-features.mjs b/test-features.mjs index 3ede803..c377713 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -4343,14 +4343,20 @@ import { __resetAuthConfig, } from './server.mjs'; -// ── Phase 2 / D45 server-side default override ──────────────────────────── -// Override the auth.allow_anonymous default (false in production) so that -// existing pre-D45 HTTP integration tests (Suite 18 etc.) that make /v1/* -// requests without an Authorization header continue to pass as anonymous. -// New Suite 20 tests explicitly call __setAuthConfig per-case to exercise -// the production-default-off path + valid key / revoked / providers_enabled -// scopes. -__setAuthConfig({ allow_anonymous: true }); +// ── Phase 2 / D45+D46 server-side default override ──────────────────────── +// Override the production-off defaults so that existing pre-D45 HTTP +// integration tests (Suite 18 etc.) continue to pass: +// - allow_anonymous: true → /v1/* requests without Authorization → anonymous +// - owner_only_endpoints: [] → /health full payload (D46 trimming opt-out) +// - fallback_detail_header_policy: 'all' → X-OLP-Fallback-Detail emitted to +// all identities (D40 v0.1.1 behaviour) +// New Suite 20 (D45) + Suite 21 (D46) explicitly call __setAuthConfig per-case +// to exercise the production-default paths. +__setAuthConfig({ + allow_anonymous: true, + owner_only_endpoints: [], + fallback_detail_header_policy: 'all', +}); // ── 13a: Trigger taxonomy ──────────────────────────────────────────────── @@ -10331,6 +10337,27 @@ describe('Suite 20 — server.mjs auth integration (D45, ADR 0007)', () => { // ── 20l: /v1/models also enforces auth ─────────────────────────────────── + describe('20m — /health with no auth + allow_anonymous=false → 401 (D46 consistent gating)', () => { + let TMP, server, port; + before(async () => { + TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20m-')); + process.env.OLP_HOME = TMP; + __setAuthConfig({ allow_anonymous: false }); + ({ server, port } = await makeSuite20Server()); + }); + after(async () => { + await teardownSuite20(server); + process.env.OLP_HOME = GLOBAL_OLP_HOME; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + rmSync(TMP, { recursive: true, force: true }); + }); + + it('20m: GET /health with no auth and allow_anonymous=false → 401', async () => { + const r = await fetch({ port, method: 'GET', path: '/health' }); + assert.equal(r.status, 401); + }); + }); + describe('20l — /v1/models also enforces auth', () => { let TMP, server, port; before(async () => { @@ -10364,3 +10391,269 @@ describe('Suite 20 — server.mjs auth integration (D45, ADR 0007)', () => { }); }); }); + +// ── Suite 21: D46 owner-vs-guest gating (ADR 0007 § 7.1 + § 7.2) ────────── +// +// HTTP-level tests for: +// - /health payload trimming (criterion #4): owner sees full per-provider +// statuses; guest + anonymous see trimmed { ok, version } +// - X-OLP-Fallback-Detail emission gating (criterion #5): policy +// 'owner_only' (default) emits only to owner; 'all' emits to everyone; +// 'none' suppresses entirely + +describe('Suite 21 — D46 owner-vs-guest gating (ADR 0007 §§ 7.1, 7.2)', () => { + const GLOBAL_OLP_HOME = process.env.OLP_HOME; + + let _suite21SavedOAuth; + function ensureSuite21FakeOAuth() { + _suite21SavedOAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'suite21-fake-oauth-token'; + } + function restoreSuite21OAuth() { + if (_suite21SavedOAuth !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = _suite21SavedOAuth; + else delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + } + + function makeSuite21Server() { + __setProvidersEnabled({ anthropic: true }); + __setSpawnImpl(makeMockSpawn(['suite21-response'])); + ensureSuite21FakeOAuth(); + const server = createOlpServer(); + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port })); + }); + } + + function teardownSuite21(server) { + return new Promise(resolve => { + __resetSpawnImpl(); + __setProvidersEnabled({}); + __clearCache(); + restoreSuite21OAuth(); + if (server) server.close(() => resolve()); + else resolve(); + }); + } + + // ── 21a-d: /health payload trimming (criterion #4) ────────────────────── + + describe('21a-d — /health payload trimming (criterion #4)', () => { + let TMP, server, port; + before(async () => { + TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-21ad-')); + process.env.OLP_HOME = TMP; + // Default-tight gating: /health is owner-only-endpoint + __setAuthConfig({ + allow_anonymous: true, + owner_only_endpoints: ['/health'], + fallback_detail_header_policy: 'owner_only', + }); + ({ server, port } = await makeSuite21Server()); + }); + after(async () => { + await teardownSuite21(server); + process.env.OLP_HOME = GLOBAL_OLP_HOME; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + rmSync(TMP, { recursive: true, force: true }); + }); + + it('21a: anonymous /health (allow_anonymous=true) → trimmed { ok, version }', async () => { + const r = await fetch({ port, method: 'GET', path: '/health' }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.equal(body.ok, true); + assert.equal(typeof body.version, 'string'); + assert.ok(!('providers' in body), 'anonymous /health MUST NOT include providers field'); + }); + + it('21b: guest /health → trimmed { ok, version }', async () => { + const { plaintext_token } = createKey({ name: '21b-guest', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/health', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.equal(body.ok, true); + assert.ok(!('providers' in body), 'guest /health MUST NOT include providers field'); + }); + + it('21c: owner /health → full payload with providers', async () => { + const { plaintext_token } = createKey({ name: '21c-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/health', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.equal(body.ok, true); + assert.ok('providers' in body, 'owner /health MUST include providers field'); + assert.equal(typeof body.providers, 'object'); + assert.equal(typeof body.providers.enabled, 'number'); + assert.ok('status' in body.providers, 'owner /health providers.status must be present'); + }); + + it('21d: owner_only_endpoints config opt-out — empty list → guest gets full payload', async () => { + __setAuthConfig({ + allow_anonymous: true, + owner_only_endpoints: [], + fallback_detail_header_policy: 'owner_only', + }); + try { + const { plaintext_token } = createKey({ name: '21d-guest-optout', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/health', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.ok('providers' in body, 'with owner_only_endpoints: [], guest /health MUST get full payload'); + } finally { + __setAuthConfig({ + allow_anonymous: true, + owner_only_endpoints: ['/health'], + fallback_detail_header_policy: 'owner_only', + }); + } + }); + }); + + // ── 21e-h: X-OLP-Fallback-Detail emission gating (criterion #5) ───────── + + describe('21e-h — X-OLP-Fallback-Detail emission gating (criterion #5)', () => { + let TMP, server, port; + before(async () => { + TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-21eh-')); + process.env.OLP_HOME = TMP; + // We will swap fallback_detail_header_policy per-case via __setAuthConfig. + __setAuthConfig({ + allow_anonymous: false, + owner_only_endpoints: [], + fallback_detail_header_policy: 'owner_only', + }); + // Wire a 2-hop chain with anthropic primary failing + openai secondary + // succeeding so X-OLP-Fallback-Detail has content to emit. + __setProvidersEnabled({ anthropic: true, openai: true }); + __setFallbackConfig({ + chains: { + 'claude-sonnet-4-6': [ + { provider: 'anthropic', model: 'claude-sonnet-4-6' }, + { provider: 'openai', model: 'gpt-5.5' }, + ], + }, + soft_triggers: {}, + providersEnabled: { anthropic: true, openai: true }, + }); + // Mock: anthropic spawn fails (exit code 1); openai succeeds. + // Use a custom spawn impl that branches on the bin name to distinguish. + // Simpler: spy on both providers — for simplicity here we use the global + // anthropic spawn mock that returns exit 1, and a real-looking codex one. + // The easier path: use the mistral-codex paired spawn — but our existing + // makeMockSpawn doesn't distinguish providers. Pattern used in Suite 18k: + // an executeHopFn that itself fails primary. Here we re-use the global + // anthropic mock that fails so the chain advances and openai serves. + __setSpawnImpl(makeMockSpawn([], 1)); // anthropic spawn fails + // Codex provider has its own __setSpawnImpl pattern; mock it separately. + ensureSuite21FakeOAuth(); + // Codex auth needs CODEX env or its own auth artifact. For tests: + process.env.OPENAI_CODEX_AUTH_PATH = '/dev/null'; // bypass to no-auth + const codexMod = await import('./lib/providers/codex.mjs'); + codexMod.__setSpawnImpl?.(makeMockSpawn(['suite21-codex-served'])); + + const serverInst = createOlpServer(); + await new Promise(resolve => { + serverInst.listen(0, '127.0.0.1', () => resolve()); + }); + server = serverInst; + port = serverInst.address().port; + }); + after(async () => { + __resetSpawnImpl(); + __setProvidersEnabled({}); + __resetFallbackConfig(); + __clearCache(); + restoreSuite21OAuth(); + delete process.env.OPENAI_CODEX_AUTH_PATH; + const codexMod = await import('./lib/providers/codex.mjs'); + codexMod.__resetSpawnImpl?.(); + await new Promise(resolve => server.close(() => resolve())); + process.env.OLP_HOME = GLOBAL_OLP_HOME; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + rmSync(TMP, { recursive: true, force: true }); + }); + + // NOTE: 21e-h tests use a contract-level audit on the header alone. + // The header surfaces ONLY for non-empty fallbackDetail (D40) AND when + // the identity is permitted per fallback_detail_header_policy (D46). + // The 2-hop chain with primary-fail provides the non-empty trail. + + it('21e: policy=owner_only + guest → X-OLP-Fallback-Detail header ABSENT', async () => { + __setAuthConfig({ + allow_anonymous: false, + owner_only_endpoints: [], + fallback_detail_header_policy: 'owner_only', + }); + const { plaintext_token } = createKey({ name: '21e-guest', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'POST', path: '/v1/chat/completions', + headers: { Authorization: `Bearer ${plaintext_token}` }, + body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '21e' }] }, + }); + // success or 502 both valid here — what matters is whether the + // detail header is present. We just need to invoke fallback. + assert.ok(r.headers['x-olp-fallback-detail'] === undefined, + 'guest identity MUST NOT receive X-OLP-Fallback-Detail under owner_only policy'); + }); + + it('21f: policy=owner_only + owner → X-OLP-Fallback-Detail header PRESENT', async () => { + __setAuthConfig({ + allow_anonymous: false, + owner_only_endpoints: [], + fallback_detail_header_policy: 'owner_only', + }); + const { plaintext_token } = createKey({ name: '21f-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'POST', path: '/v1/chat/completions', + headers: { Authorization: `Bearer ${plaintext_token}` }, + body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '21f' }] }, + }); + assert.ok(r.headers['x-olp-fallback-detail'] !== undefined, + 'owner identity MUST receive X-OLP-Fallback-Detail when fallback chain has failures'); + // Validate header is JSON-parseable per D40 contract + const parsed = JSON.parse(r.headers['x-olp-fallback-detail']); + assert.ok(Array.isArray(parsed)); + }); + + it('21g: policy=all + guest → X-OLP-Fallback-Detail header PRESENT (v0.1.1 opt-back-in)', async () => { + __setAuthConfig({ + allow_anonymous: false, + owner_only_endpoints: [], + fallback_detail_header_policy: 'all', + }); + const { plaintext_token } = createKey({ name: '21g-guest-all', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'POST', path: '/v1/chat/completions', + headers: { Authorization: `Bearer ${plaintext_token}` }, + body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '21g' }] }, + }); + assert.ok(r.headers['x-olp-fallback-detail'] !== undefined, + 'policy=all MUST emit X-OLP-Fallback-Detail to guest identity (D46 opt-back-in to v0.1.1)'); + }); + + it('21h: policy=none + owner → X-OLP-Fallback-Detail header ABSENT (full suppression)', async () => { + __setAuthConfig({ + allow_anonymous: false, + owner_only_endpoints: [], + fallback_detail_header_policy: 'none', + }); + const { plaintext_token } = createKey({ name: '21h-owner-none', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'POST', path: '/v1/chat/completions', + headers: { Authorization: `Bearer ${plaintext_token}` }, + body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '21h' }] }, + }); + assert.ok(r.headers['x-olp-fallback-detail'] === undefined, + 'policy=none MUST suppress X-OLP-Fallback-Detail even for owner identity'); + }); + }); +});