mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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:
@@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user