feat+test+docs: D46 — owner-vs-guest gating for /health + X-OLP-Fallback-Detail (Phase 2) (#22)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-25 14:42:24 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent 40064955ab
commit 06f619120d
5 changed files with 412 additions and 22 deletions
+2 -2
View File
@@ -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/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/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/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).** - `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.** - `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. - `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. - `.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). - `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).
--- ---
+24
View File
@@ -4,6 +4,30 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
## Unreleased ## 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) ### 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. 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.
+2 -2
View File
@@ -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 | | 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 | | `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`) | | `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. | | `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 | | `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 | | `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. - **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. - **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). **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).
+83 -10
View File
@@ -54,6 +54,7 @@ import {
touchLastUsed, touchLastUsed,
loadAuthConfigSync, loadAuthConfigSync,
ANONYMOUS_KEY_ID, ANONYMOUS_KEY_ID,
ENV_OWNER_KEY_ID,
} from './lib/keys.mjs'; } from './lib/keys.mjs';
import { appendAuditEvent } from './lib/audit.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 * Identity-aware gate for X-OLP-Fallback-Detail emission per ADR 0007 § 7.2.
* failure tuples are non-empty. Returns the base object unchanged otherwise. * 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<string,string>} baseHeaders * @param {Record<string,string>} baseHeaders
* @param {Array<object>|null|undefined} fallbackDetail * @param {Array<object>|null|undefined} fallbackDetail
* @param {{owner_tier?: string}|null|undefined} olpIdentity - request identity; null on pre-auth paths
* @returns {Record<string,string>} * @returns {Record<string,string>}
*/ */
function withFallbackDetailHeader(baseHeaders, fallbackDetail) { function withFallbackDetailHeader(baseHeaders, fallbackDetail, olpIdentity) {
if (!shouldEmitFallbackDetailHeader(olpIdentity)) return baseHeaders;
const value = serializeFallbackDetailHeader(fallbackDetail); const value = serializeFallbackDetailHeader(fallbackDetail);
if (value === null) return baseHeaders; if (value === null) return baseHeaders;
return { ...baseHeaders, 'X-OLP-Fallback-Detail': value }; return { ...baseHeaders, 'X-OLP-Fallback-Detail': value };
@@ -486,14 +513,59 @@ function isProviderEnabled(authContext, providerKey) {
/** /**
* GET /health * 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 * Phase 2 / D46 (ADR 0007 § 7.1): identity-aware payload.
* payload trimming per ADR 0007 § 7.1 lands at D46. * - 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) { 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 enabled = loadedProviders.size;
const available = listAllProviderNames().length; const available = listAllProviderNames().length;
const providerStatuses = {}; const providerStatuses = {};
@@ -1323,7 +1395,7 @@ async function handleChatCompletions(req, res) {
type: 'provider_error', type: 'provider_error',
}, },
}); });
const detailHeader = withFallbackDetailHeader({}, fallbackDetail); const detailHeader = withFallbackDetailHeader({}, fallbackDetail, olpIdentity);
res.writeHead(errStatus, { res.writeHead(errStatus, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload), 'Content-Length': Buffer.byteLength(payload),
@@ -1355,6 +1427,7 @@ async function handleChatCompletions(req, res) {
const headers = withFallbackDetailHeader( const headers = withFallbackDetailHeader(
olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }), olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }),
fallbackDetail, fallbackDetail,
olpIdentity,
); );
// Audit ctx capture for success path (audit fires on res.on('finish'); // Audit ctx capture for success path (audit fires on res.on('finish');
+301 -8
View File
@@ -4343,14 +4343,20 @@ import {
__resetAuthConfig, __resetAuthConfig,
} from './server.mjs'; } from './server.mjs';
// ── Phase 2 / D45 server-side default override ──────────────────────────── // ── Phase 2 / D45+D46 server-side default override ────────────────────────
// Override the auth.allow_anonymous default (false in production) so that // Override the production-off defaults so that existing pre-D45 HTTP
// existing pre-D45 HTTP integration tests (Suite 18 etc.) that make /v1/* // integration tests (Suite 18 etc.) continue to pass:
// requests without an Authorization header continue to pass as anonymous. // - allow_anonymous: true → /v1/* requests without Authorization anonymous
// New Suite 20 tests explicitly call __setAuthConfig per-case to exercise // - owner_only_endpoints: [] → /health full payload (D46 trimming opt-out)
// the production-default-off path + valid key / revoked / providers_enabled // - fallback_detail_header_policy: 'all' → X-OLP-Fallback-Detail emitted to
// scopes. // all identities (D40 v0.1.1 behaviour)
__setAuthConfig({ allow_anonymous: true }); // 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 ──────────────────────────────────────────────── // ── 13a: Trigger taxonomy ────────────────────────────────────────────────
@@ -10331,6 +10337,27 @@ describe('Suite 20 — server.mjs auth integration (D45, ADR 0007)', () => {
// ── 20l: /v1/models also enforces auth ─────────────────────────────────── // ── 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', () => { describe('20l — /v1/models also enforces auth', () => {
let TMP, server, port; let TMP, server, port;
before(async () => { 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');
});
});
});