mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat+test+docs: D45 — server.mjs auth integration + lib/audit.mjs (#21)
* feat+test+docs: 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 ADR § 10 acceptance criteria #1 (per-key cache isolation), #2 (anonymous prod-default off), #3 (anonymous dev-mode on), #6 (post-revoke 401 within next request — full coverage with D45), #8 (audit ndjson round-trip), #10 (OLP_OWNER_TOKEN env override — full server-side coverage), #11 (providers_enabled 403 scope). Owner-vs-guest gating for /health + X-OLP-Fallback-Detail (criteria #4, #5) remains in D46 scope. NEW lib/audit.mjs (~110 lines): - appendAuditEvent(event, opts): one JSON event per line to ~/.olp/logs/audit.ndjson (file 0600, dir 0700). § 6.2 retry: warn + 1 retry; per-process drop counter + warn on second failure; NEVER throws. Per-call OLP_HOME env resolution (matches lib/keys.mjs). - getAuditDropCount(): for future /health surface. lib/keys.mjs extended: - loadAuthConfigSync({ olpHome }): reads auth block from ~/.olp/config.json with ADR § 7.2 defaults (allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only'). Never throws; missing file / malformed JSON falls back to defaults. - _resolveOlpHome(opts): precedence opts.olpHome → process.env.OLP_HOME → ~/.olp. Per-call resolution so tests + operator deployments can redirect without code edits. server.mjs auth middleware integration: - extractToken(req): parses Authorization Bearer / x-api-key. - authenticate(req): validateKey + 401 paths (auth_required vs invalid_or_revoked_key). - isProviderEnabled(olpIdentity, providerKey): '*' = all; else array allowlist. - _authConfig loaded at startup; warn auth_allow_anonymous_enabled when true. Test seams __setAuthConfig / __resetAuthConfig. - handleChatCompletions + handleModels both gated by authenticate at top. Audit ctx built throughout; res.on('finish') appends row + fires touchLastUsed async. - IDENTITY-VS-CREDENTIALS SEPARATION: olpIdentity (new validated identity) consumed for cache namespacing + providers_enabled + audit; authContext passed to provider.spawn() REMAINS null so providers continue their own credential discovery (env / keychain / file). Per-provider per-key credential mapping is Phase 3+ per ADR § 12. - handleChatCompletions chain filtered by isProviderEnabled; empty result returns 403 key_no_provider_access. - keyId = olpIdentity.keyId (replacing hardcoded '__anonymous__'). - Audit captures fields throughout: post-auth, post-IR, post-chain (success or exhausted). Status + latency populated on res.on('finish'). TESTS — Suite 20, +15 (499 → 514): 20a-d: header parsing + valid key happy paths (Bearer / x-api-key / invalid → 401) 20e: revoked key 401 (criterion #6 end-to-end) 20f: OLP_OWNER_TOKEN env override returns 200 (criterion #10 full) 20g: allow_anonymous=true + no header returns 200 (criterion #3) 20h + 20h-extra: providers_enabled=['mistral'] for anthropic model → 403; '*' baseline returns 200 (criterion #11) 20i: per-key cache namespace isolation (criterion #1 end-to-end) 20j + 20j-401: audit.ndjson written with § 8 schema fields + PII guard; 401 path also appends (criterion #8) 20k: filesystem key last_used_at populated post-request (D45 touch wire) 20l + 20l-200: /v1/models also enforces auth TEST-MODE SETUP (test-features.mjs): - process.env.OLP_HOME = mkdtempSync(...) at module load so audit + key writes don't pollute ~/.olp/. - __setAuthConfig({ allow_anonymous: true }) after server.mjs imports so pre-D45 HTTP integration tests (Suite 18 etc.) continue to pass. - Suite 20 explicitly overrides __setAuthConfig per-case to exercise production-default-off coverage. DOCUMENTATION: - AGENTS.md: lib/keys.mjs 🟡 marker updated + NEW lib/audit.mjs entry; Implementation-status-note + shipped-set updated. - README.md: Implementation Status table gains lib/audit.mjs row + lib/keys.mjs row updated; Known limitations Multi-key auth note rewritten to reflect D45 ship + D46 follow-up; new env-vars (OLP_HOME, OLP_OWNER_TOKEN) and auth config block surfaced. - CHANGELOG.md: D45 entry under Unreleased per release_kit overlay phase_rolling_mode discipline. AUTHORITY: - ADR 0007 (multi-key auth — §§ 5/6.2/7/9.4 implementation contracts + § 10 criteria #1/#2/#3/#6/#8/#10/#11 covered). - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. - Phase 2 kickoff handoff (~/.cc-rules/memory/handoffs/ 2026-05-25-phase-2-kickoff.md in cc-rules d9da966). - Standing autopilot grant (~/.cc-rules/memory/auto/ standing_autopilot_phase_2.md in cc-rules bf0ed9a). Verified: 514/514 pass via npm test (no regression in 499 existing tests; 15 new Suite 20 tests all green). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix+test+docs: D45 fold-in — CI fail recovery + opus reviewer P1/P2/P3 Fresh-context opus reviewer (PR #21) returned APPROVE_WITH_MINOR with 4 findings; CI Node 24 separately reported 9 Suite 20 failures (all 200-expecting tests). Root cause of CI: Suite 20 setup did not stub CLAUDE_CODE_OAUTH_TOKEN before mock spawn, so anthropic.mjs AUTH_MISSING pre-check fired and tests 502'd. (Local Node 22 had the env from the maintainer's claude install — masked the gap.) CI FIX — Suite 20 OAuth env stub Added ensureSuite20FakeOAuth / restoreSuite20OAuth helpers in makeSuite20Server / teardownSuite20. Matches the existing pattern in Suite 9 line ~2154 (test-fake-oauth-token-for-cache-tests). P1 — Real-streaming path audit fidelity Single-hop streaming success (server.mjs ~L1050, the most common deployed shape) did not populate auditCtx.provider / tried_providers / cache_status. Audit rows for streaming requests carried provider: null. Fixed by stamping these at the top of the streaming branch and amending error_code on the two streaming failure exit paths (streaming_error_after_first_chunk + streaming_error_before_first_chunk). New regression test 20j-stream: streaming request asserts the audit row's provider, cache_status, and tried_providers fields are populated. P2 — Global test tmpdir cleanup process.env.OLP_HOME = mkdtempSync(...) at test-features.mjs module load left /var/folders/.../olp-test-home-* leak per npm test run. Fixed by process.on('exit', () => rmSync(_GLOBAL_TEST_OLP_HOME)). Best-effort; swallows errors so exit handler never throws. P3 — handleModels 401 lacks OLP diagnostic headers handleChatCompletions 401 passes olpErrorHeaders({ startMs }); handleModels 401 did not. Aligned. DEFERRED — P2 tried_providers semantics on 403 Reviewer noted that key_no_provider_access 403 stamps original chain in tried_providers, but the field name implies hops actually dispatched. Either ADR § 8 amendment or D46+ semantic fix. Marked in CHANGELOG; not in this fold-in scope. Test count: 514 → 515 (+1 streaming-audit regression test 20j-stream; 14 existing Suite 20 tests still pass). Verified locally via npm test. CI Node 24 recovery via the OAuth env stub. Authority: PR #21 fresh-context opus reviewer findings; CI Node 24 run 26382758946 failure logs; CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. 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,8 @@ 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, audit log. Carries OCP's per-key isolation model into OLP. **🟡 Phase 2 — core landed at D44 (createKey / validateKey / listKeys / revokeKey / touchLastUsed per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4). server.mjs integration scheduled D45; audit ndjson + keygen CLI surface in subsequent D-days.**
|
- `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/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.
|
||||||
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
||||||
@@ -45,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 D44 is: `server.mjs`, `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `lib/keys.mjs` (core only — D44), `models-registry.json`, `test-features.mjs`. Phase 2 in progress: `lib/keys.mjs` server integration (D45), owner gating for /health + X-OLP-Fallback-Detail (D46), E2E + multi-key fixtures (D47), 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 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,43 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
- **New file `lib/audit.mjs`** (~75 lines): `appendAuditEvent(event, opts)` writes one JSON event per line to `~/.olp/logs/audit.ndjson` (file 0600, dir 0700). § 6.2 retry semantics: warn + 1 retry on first failure; per-process drop counter + warn on second failure; NEVER throws. Per-call `OLP_HOME` env resolution (matches `lib/keys.mjs`). Exports `getAuditDropCount` for future /health surface.
|
||||||
|
- **`lib/keys.mjs`** extended with `loadAuthConfigSync({ olpHome })` reading the `auth` block from `~/.olp/config.json` with defaults per ADR § 7.2 (`allow_anonymous: false`, `owner_only_endpoints: ['/health']`, `fallback_detail_header_policy: 'owner_only'`). Both `lib/keys.mjs` + `lib/audit.mjs` now resolve `OLP_HOME` env per call (precedence: opts.olpHome → process.env.OLP_HOME → ~/.olp) so tests and operator deployments can redirect without code edits.
|
||||||
|
- **`server.mjs` auth middleware integration:**
|
||||||
|
- `extractToken(req)` parses `Authorization: Bearer <token>` first, then `x-api-key: <token>`.
|
||||||
|
- `authenticate(req)` calls `validateKey(token, { allowAnonymous: _authConfig.allow_anonymous })`; returns identity on success, 401 `{ auth_required | invalid_or_revoked_key }` on failure.
|
||||||
|
- `isProviderEnabled(olpIdentity, providerKey)` enforces `providers_enabled` allowlist (`'*'` = all).
|
||||||
|
- `_authConfig` loaded at startup; warn `auth_allow_anonymous_enabled` fires if `allow_anonymous: true` so the relaxed posture is visible. Test seams `__setAuthConfig` / `__resetAuthConfig`.
|
||||||
|
- `handleChatCompletions` and `handleModels` both gated by `authenticate(req)` at top. Audit ctx object built throughout the handler lifecycle; `res.on('finish')` appends the row + fires `touchLastUsed` async (best-effort).
|
||||||
|
- **Identity-vs-credentials separation:** `olpIdentity` (the new validated identity) is consumed for cache namespacing + providers_enabled + audit; `authContext` passed to `provider.spawn()` REMAINS `null` so providers continue their own credential discovery (env / keychain / file). Per-provider per-key credential mapping is Phase 3+ scope per ADR § 12.
|
||||||
|
- `handleChatCompletions` chain filtered by `chain.filter(hop => isProviderEnabled(olpIdentity, hop.provider))`; empty result returns 403 `key_no_provider_access` with helpful diagnostic message.
|
||||||
|
- `keyId = olpIdentity.keyId` (replacing hardcoded `'__anonymous__'` at the cache call sites).
|
||||||
|
- Audit captures fields throughout: post-auth (key_id, owner_tier); post-IR (model); post-chain-success (provider, fallback_hops, tried_providers, cache_status); post-chain-exhausted (error_code, providerUsed=chain[0], cache_status='miss'). Status code + latency populated on `res.on('finish')`.
|
||||||
|
- **Test surface (Suite 20, +15 tests, 499 → 514):**
|
||||||
|
- 20a-d: header parsing + valid key happy paths (Bearer / x-api-key / invalid → 401)
|
||||||
|
- 20e: revoked key 401 (closes criterion #6 end-to-end)
|
||||||
|
- 20f: `OLP_OWNER_TOKEN` env override returns 200 (criterion #10 full coverage)
|
||||||
|
- 20g: `allow_anonymous: true` + no header returns 200 (criterion #3)
|
||||||
|
- 20h + 20h-extra: `providers_enabled: ['mistral']` for anthropic model → 403; `'*'` baseline returns 200 (criterion #11)
|
||||||
|
- 20i: per-key cache namespace isolation — keys A/B with identical payload do not share cache (criterion #1 end-to-end)
|
||||||
|
- 20j + 20j-401: audit.ndjson written with § 8 schema fields including PII guard; 401 path also appends (criterion #8)
|
||||||
|
- 20k: filesystem key `last_used_at` populated after first successful request (D45 touchLastUsed wire)
|
||||||
|
- 20l + 20l-200: `/v1/models` also enforces auth (consistent gating across `/v1/*`)
|
||||||
|
- **Test-mode setup:** test-features.mjs sets `process.env.OLP_HOME` to a tmpdir at module load so audit + key writes don't pollute `~/.olp/`. After the server.mjs import resolves, calls `__setAuthConfig({ allow_anonymous: true })` so pre-D45 HTTP integration tests (Suite 18 etc.) that don't pass an Authorization header continue to pass as anonymous; Suite 20 explicitly overrides per-case for production-default-off coverage.
|
||||||
|
- **Documentation:** AGENTS.md `lib/keys.mjs` 🟡 marker updated + new `lib/audit.mjs` entry; AGENTS.md Implementation-status-note + shipped-set updated. README.md Implementation Status table gains `lib/audit.mjs` row + `lib/keys.mjs` row updated; Known limitations "Multi-key auth" note rewritten to reflect D45 ship + D46 follow-up; new env-vars and config block surfaced for users.
|
||||||
|
- **Test count:** 499 → 515 (+15 initial Suite 20 tests + 1 fold-in regression test `20j-stream` covering opus-P1 streaming audit-fidelity).
|
||||||
|
- **Fold-in (CI-fail recovery + fresh-context opus reviewer findings, 1 CI + 1 P1 + 2 P2 + 1 P3):**
|
||||||
|
- **CI Node 24 failure** — Suite 20 setup did not stub `CLAUDE_CODE_OAUTH_TOKEN` before the mock spawn ran; lib/providers/anthropic.mjs `_spawnAndStream` checks for an OAuth token BEFORE invoking the (mock) spawn, so the AUTH_MISSING pre-check fired and every Suite 20 200-expecting test 502'd on CI Node 24 (local Node 22 had the env from the maintainer's claude install). Fixed by `ensureSuite20FakeOAuth` / `restoreSuite20OAuth` helpers in `makeSuite20Server` / `teardownSuite20`; matches the existing pattern used at Suite 9 line ~2154 (`test-fake-oauth-token-for-cache-tests`).
|
||||||
|
- **P1 real-streaming audit fidelity** — single-hop streaming success path (server.mjs ~L1050+ `if (ir.stream && chain.length === 1 && !bypassCacheForFirstHop ...)`) did not populate `auditCtx.provider` / `tried_providers` / `cache_status`, so audit rows for the most common deployed shape carried `provider: null`. Fixed by stamping these fields at the top of the streaming branch (between the streamPlugin null-check and the `streamHeaders` build) and amending `error_code` on the two streaming failure exit paths (`streaming_error_after_first_chunk` + `streaming_error_before_first_chunk`). New regression test `20j-stream` makes a streaming request and asserts the audit row's `provider`, `cache_status`, and `tried_providers` fields are populated.
|
||||||
|
- **P2 global test tmpdir cleanup** — `process.env.OLP_HOME = mkdtempSync(...)` at module load left a `/var/folders/.../olp-test-home-*` directory leak per `npm test` run. Fixed by `process.on('exit', () => rmSync(...))` registered immediately after the mkdtempSync. Best-effort; never throws at exit.
|
||||||
|
- **P3 handleModels 401 lacks OLP diagnostic headers** — `handleChatCompletions` 401 path passes `olpErrorHeaders({ startMs })` but `handleModels` did not. Aligned by adding the same headers to the `handleModels` `authResult.ok=false` return.
|
||||||
|
- **Deferred (acknowledged by reviewer as non-blocking):** P2 `tried_providers` semantics on `key_no_provider_access` 403 — schema currently reports filter-rejected hops as "tried" which a downstream Dashboard would misread; either ADR § 8 amendment (rename / add field) or D46+ semantic fix.
|
||||||
|
- **Authority:** ADR 0007 (multi-key auth — §§ 5/6.2/7/9.4 implementation contracts + § 10 acceptance criteria #1/#2/#3/#6/#8/#10/#11); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; Phase 2 kickoff handoff (`~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` in cc-rules `d9da966`); standing autopilot grant (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a`).
|
||||||
|
|
||||||
### D44 — `lib/keys.mjs` core landed (multi-key auth, no server wire-up yet)
|
### D44 — `lib/keys.mjs` core landed (multi-key auth, no server wire-up yet)
|
||||||
|
|
||||||
First Phase 2 implementation D-day. Lands the `lib/keys.mjs` module per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4. Identity / lifecycle layer for OLP API keys is now in-tree; `server.mjs` integration scheduled D45 (until then, requests still use the hardcoded `'__anonymous__'` cache namespace — no behavioural change at v0.1.1 / D44).
|
First Phase 2 implementation D-day. Lands the `lib/keys.mjs` module per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4. Identity / lifecycle layer for OLP API keys is now in-tree; `server.mjs` integration scheduled D45 (until then, requests still use the hardcoded `'__anonymous__'` cache namespace — no behavioural change at v0.1.1 / D44).
|
||||||
|
|||||||
@@ -182,7 +182,8 @@ 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 landed (D44) | Multi-key auth core (`createKey` / `validateKey` / `listKeys` / `revokeKey` / `touchLastUsed`) per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4. Atomic manifest writes + per-key write-lock + revoke-dominates-touch + env override. `server.mjs` integration scheduled D45; audit ndjson + keygen CLI surface follow. |
|
| `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/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 |
|
||||||
| `docs/openai-spec-pin.md` | ✅ Shipped (D30) | OpenAI spec snapshot for annual audit; v0.1 baseline pinned 2026-05-24 |
|
| `docs/openai-spec-pin.md` | ✅ Shipped (D30) | OpenAI spec snapshot for annual audit; v0.1 baseline pinned 2026-05-24 |
|
||||||
@@ -196,7 +197,11 @@ 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 core landed, server integration pending.** `lib/keys.mjs` core (createKey / validateKey / listKeys / revokeKey / touchLastUsed) shipped at D44 per ADR 0007. All requests at v0.1.1 / D44 still share the cache namespace `__anonymous__` because `server.mjs` integration is scheduled for D45 — until that lands, the identity layer exists in-tree but no request flow consults it. Phase 2 in progress through D45 (server wire-up), D46 (owner gating), D47 (E2E + multi-key fixtures), Phase 2 close → v0.2.0. Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md).
|
- **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).
|
||||||
|
|
||||||
|
**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 config block consumed at D45:** `config.json auth.{ allow_anonymous, owner_only_endpoints, fallback_detail_header_policy }`. Default `allow_anonymous: false` (production-off); set true to accept requests without an OLP API key (development / single-user dev mode). Startup emits a warn when `allow_anonymous: true` so the relaxed posture is observable.
|
||||||
- **Provider-level `cacheKeyFields` mask not implemented.** Cache keys include every IR field including ones individual plugins drop at spawn (e.g., Anthropic plugin drops `temperature`). Spurious cache misses possible (extra spawn cost; never spurious hits). Conservative posture documented in [ADR 0005 Amendment 7](./docs/adr/0005-cache-cross-provider.md). Tracked in [v1.x roadmap #5](./docs/v1x-roadmap.md).
|
- **Provider-level `cacheKeyFields` mask not implemented.** Cache keys include every IR field including ones individual plugins drop at spawn (e.g., Anthropic plugin drops `temperature`). Spurious cache misses possible (extra spawn cost; never spurious hits). Conservative posture documented in [ADR 0005 Amendment 7](./docs/adr/0005-cache-cross-provider.md). Tracked in [v1.x roadmap #5](./docs/v1x-roadmap.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* lib/audit.mjs — OLP audit ndjson append (Phase 2 / D45)
|
||||||
|
*
|
||||||
|
* Authority: ADR 0007 § 6.2 (audit append semantics) + § 8 (event schema).
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - One JSON event per line; newline-terminated; UTF-8.
|
||||||
|
* - Append to ~/.olp/logs/audit.ndjson (chmod 0600 file, 0700 dir).
|
||||||
|
* - On append failure: log a warn ('audit_append_failed_once') + retry
|
||||||
|
* once synchronously.
|
||||||
|
* - On second-failure: increment per-process drop counter + log warn
|
||||||
|
* ('audit_append_dropped'); NEVER throw to the caller (audit is
|
||||||
|
* observability, not authorization). Per § 6.2.
|
||||||
|
* - No memory buffer at Phase 2 (forward-path note in ADR § 13).
|
||||||
|
*
|
||||||
|
* Atomicity note: Node's `fs.appendFileSync` opens with O_APPEND which is
|
||||||
|
* POSIX-atomic for writes <= PIPE_BUF (typically 4096 bytes). Our event
|
||||||
|
* payloads (§ 8 schema with hash + shape fields, no PII / no message
|
||||||
|
* content) are well under that limit, so concurrent in-process appends
|
||||||
|
* are line-atomic without explicit locking.
|
||||||
|
*
|
||||||
|
* No PII: § 8 explicitly excludes request body, response body, and IR
|
||||||
|
* message content. Hash + shape only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { appendFileSync, mkdirSync, chmodSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
||||||
|
const OLP_HOME_ENV = 'OLP_HOME';
|
||||||
|
const RETRY_COUNT = 1; // § 6.2: warn + 1 retry
|
||||||
|
|
||||||
|
let _dropCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve OLP home dir (matches lib/keys.mjs precedence): opts.olpHome →
|
||||||
|
* process.env.OLP_HOME → ~/.olp. Resolved per call so tests setting the
|
||||||
|
* env mid-run take effect.
|
||||||
|
*/
|
||||||
|
function _resolveOlpHome(opts) {
|
||||||
|
if (opts?.olpHome) return opts.olpHome;
|
||||||
|
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
|
||||||
|
return DEFAULT_OLP_HOME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a single audit event to ~/.olp/logs/audit.ndjson.
|
||||||
|
*
|
||||||
|
* @param {object} event - § 8 schema fields (ts, key_id, owner_tier,
|
||||||
|
* method, path, provider, model, status_code, latency_ms, cache_status,
|
||||||
|
* fallback_hops, tried_providers, error_code, ir_request_hash, chain_id).
|
||||||
|
* Caller is responsible for populating fields; missing fields are
|
||||||
|
* serialized as undefined → omitted by JSON.stringify.
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
||||||
|
* @param {(level: string, event: string, data?: object) => void} [opts.logEvent]
|
||||||
|
* - injectable structured logger; defaults to console.warn with JSON line
|
||||||
|
*/
|
||||||
|
export function appendAuditEvent(event, opts = {}) {
|
||||||
|
const olpHome = _resolveOlpHome(opts);
|
||||||
|
const logsDir = join(olpHome, 'logs');
|
||||||
|
const path = join(logsDir, 'audit.ndjson');
|
||||||
|
const line = JSON.stringify(event) + '\n';
|
||||||
|
const logEvent = opts.logEvent ?? ((level, ev, data) => {
|
||||||
|
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
||||||
|
process.stderr.write(JSON.stringify(entry) + '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
||||||
|
try {
|
||||||
|
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
||||||
|
// Tighten dir mode in case it already existed with broader permissions.
|
||||||
|
try { chmodSync(logsDir, 0o700); } catch { /* tolerate EPERM */ }
|
||||||
|
appendFileSync(path, line, { mode: 0o600 });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt < RETRY_COUNT) {
|
||||||
|
logEvent('warn', 'audit_append_failed_once', {
|
||||||
|
path,
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_dropCounter++;
|
||||||
|
logEvent('warn', 'audit_append_dropped', {
|
||||||
|
path,
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
drop_count: _dropCounter,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-process count of audit events dropped due to repeated append failure.
|
||||||
|
* Useful for /health observability surface (D46).
|
||||||
|
*/
|
||||||
|
export function getAuditDropCount() {
|
||||||
|
return _dropCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: reset the drop counter to zero. Suite 20 uses this between
|
||||||
|
* cases to assert independent failure-handling counts.
|
||||||
|
*/
|
||||||
|
export function __resetAuditDropCount() {
|
||||||
|
_dropCounter = 0;
|
||||||
|
}
|
||||||
+61
-1
@@ -45,6 +45,20 @@ export const ENV_OWNER_KEY_ID = '__env_owner__';
|
|||||||
export const ENV_OWNER_VAR = 'OLP_OWNER_TOKEN';
|
export const ENV_OWNER_VAR = 'OLP_OWNER_TOKEN';
|
||||||
|
|
||||||
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
||||||
|
export const OLP_HOME_ENV = 'OLP_HOME';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OLP home directory. Precedence:
|
||||||
|
* 1. `opts.olpHome` (explicit caller override — tests, CLI flags)
|
||||||
|
* 2. `process.env.OLP_HOME` (operator / CI env override)
|
||||||
|
* 3. `~/.olp` (default per ADR 0007 § 3)
|
||||||
|
* Resolved dynamically per call so tests setting OLP_HOME mid-run take effect.
|
||||||
|
*/
|
||||||
|
function _resolveOlpHome(opts) {
|
||||||
|
if (opts?.olpHome) return opts.olpHome;
|
||||||
|
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
|
||||||
|
return DEFAULT_OLP_HOME;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal state ────────────────────────────────────────────────────────
|
// ── Internal state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -60,7 +74,7 @@ let _touchInterleaveHook = async () => {};
|
|||||||
|
|
||||||
// ── Path helpers ──────────────────────────────────────────────────────────
|
// ── Path helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function _olpHome(opts) { return opts?.olpHome ?? DEFAULT_OLP_HOME; }
|
function _olpHome(opts) { return _resolveOlpHome(opts); }
|
||||||
function _keysDir(opts) { return join(_olpHome(opts), 'keys'); }
|
function _keysDir(opts) { return join(_olpHome(opts), 'keys'); }
|
||||||
function _keyDir(id, opts) { return join(_keysDir(opts), id); }
|
function _keyDir(id, opts) { return join(_keysDir(opts), id); }
|
||||||
function _manifestPath(id, opts) { return join(_keyDir(id, opts), 'manifest.json'); }
|
function _manifestPath(id, opts) { return join(_keyDir(id, opts), 'manifest.json'); }
|
||||||
@@ -431,6 +445,52 @@ export async function touchLastUsed(id, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auth config loader (§ 7.2) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the `auth` block from ~/.olp/config.json. All fields defaulted
|
||||||
|
* so partial / absent config is safe.
|
||||||
|
*
|
||||||
|
* Defaults per ADR § 7.2:
|
||||||
|
* - allow_anonymous: false (production-off default)
|
||||||
|
* - owner_only_endpoints: ['/health'] (D46 consumes; D45 only loads)
|
||||||
|
* - fallback_detail_header_policy: 'owner_only' (D46 consumes; D45 only loads)
|
||||||
|
*
|
||||||
|
* Returns the auth config object. Never throws — missing file / parse
|
||||||
|
* error / missing `auth` key all fall back to defaults.
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
||||||
|
* @returns {{ allow_anonymous: boolean, owner_only_endpoints: string[], fallback_detail_header_policy: 'owner_only'|'all'|'none' }}
|
||||||
|
*/
|
||||||
|
export function loadAuthConfigSync(opts = {}) {
|
||||||
|
const olpHome = _resolveOlpHome(opts);
|
||||||
|
const path = join(olpHome, 'config.json');
|
||||||
|
const DEFAULTS = {
|
||||||
|
allow_anonymous: false,
|
||||||
|
owner_only_endpoints: ['/health'],
|
||||||
|
fallback_detail_header_policy: 'owner_only',
|
||||||
|
};
|
||||||
|
if (!existsSync(path)) return { ...DEFAULTS };
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(path, 'utf-8');
|
||||||
|
const cfg = JSON.parse(raw);
|
||||||
|
const auth = (cfg && typeof cfg === 'object' && cfg.auth && typeof cfg.auth === 'object')
|
||||||
|
? cfg.auth
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
allow_anonymous: typeof auth.allow_anonymous === 'boolean' ? auth.allow_anonymous : DEFAULTS.allow_anonymous,
|
||||||
|
owner_only_endpoints: Array.isArray(auth.owner_only_endpoints) ? auth.owner_only_endpoints : DEFAULTS.owner_only_endpoints,
|
||||||
|
fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy)
|
||||||
|
? auth.fallback_detail_header_policy
|
||||||
|
: DEFAULTS.fallback_detail_header_policy,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON / unreadable file → safe defaults
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Test-only hooks ───────────────────────────────────────────────────────
|
// ── Test-only hooks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+271
-10
@@ -48,6 +48,14 @@ import {
|
|||||||
buildDefaultChain,
|
buildDefaultChain,
|
||||||
loadFallbackConfigSync,
|
loadFallbackConfigSync,
|
||||||
} from './lib/fallback/engine.mjs';
|
} from './lib/fallback/engine.mjs';
|
||||||
|
// Phase 2 / D45 — multi-key auth integration per ADR 0007.
|
||||||
|
import {
|
||||||
|
validateKey,
|
||||||
|
touchLastUsed,
|
||||||
|
loadAuthConfigSync,
|
||||||
|
ANONYMOUS_KEY_ID,
|
||||||
|
} from './lib/keys.mjs';
|
||||||
|
import { appendAuditEvent } from './lib/audit.mjs';
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -93,6 +101,25 @@ if (_softTriggersConfigured) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auth config (Phase 2 / D45, ADR 0007 § 7.2) ───────────────────────────
|
||||||
|
// auth.allow_anonymous default false (production-off). Test seam below.
|
||||||
|
let _authConfig = loadAuthConfigSync();
|
||||||
|
if (_authConfig.allow_anonymous === true) {
|
||||||
|
logEvent('warn', 'auth_allow_anonymous_enabled', {
|
||||||
|
message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
|
||||||
|
export function __setAuthConfig(config) {
|
||||||
|
_authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — reset auth config to file-loaded state. */
|
||||||
|
export function __resetAuthConfig() {
|
||||||
|
_authConfig = loadAuthConfigSync();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Provider registry ─────────────────────────────────────────────────────
|
// ── Provider registry ─────────────────────────────────────────────────────
|
||||||
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
|
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
|
||||||
// ~/.olp/config.json has providers.enabled.X = true.
|
// ~/.olp/config.json has providers.enabled.X = true.
|
||||||
@@ -383,11 +410,88 @@ function withFallbackDetailHeader(baseHeaders, fallbackDetail) {
|
|||||||
|
|
||||||
// ── Route handlers ────────────────────────────────────────────────────────
|
// ── Route handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Auth middleware (Phase 2 / D45, ADR 0007 § 5 + § 7) ───────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the plaintext OLP token from request headers.
|
||||||
|
* Tries `Authorization: Bearer <token>` first, then `x-api-key: <token>`.
|
||||||
|
* Returns the token string or null.
|
||||||
|
*
|
||||||
|
* @param {import('node:http').IncomingMessage} req
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function extractToken(req) {
|
||||||
|
const auth = req.headers['authorization'];
|
||||||
|
if (typeof auth === 'string') {
|
||||||
|
const match = /^Bearer\s+(\S+)$/i.exec(auth);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
const xKey = req.headers['x-api-key'];
|
||||||
|
if (typeof xKey === 'string' && xKey.length > 0) return xKey;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate the request per ADR 0007 §§ 5 / 7 / 9.4.
|
||||||
|
* - Try env-owner override first (OLP_OWNER_TOKEN).
|
||||||
|
* - Then filesystem manifest lookup by SHA-256 hash.
|
||||||
|
* - Else, if auth.allow_anonymous is true → anonymous identity.
|
||||||
|
* - Else → 401 auth_required.
|
||||||
|
*
|
||||||
|
* Returns { ok: true, authContext } on success or
|
||||||
|
* { ok: false, status, code, message } on failure (401).
|
||||||
|
*
|
||||||
|
* @param {import('node:http').IncomingMessage} req
|
||||||
|
* @returns {{ ok: true, authContext: { keyId: string, owner_tier: 'owner'|'guest'|'anonymous', providers_enabled: string[]|'*', source: 'env'|'filesystem'|'anonymous' } } | { ok: false, status: number, code: string, message: string }}
|
||||||
|
*/
|
||||||
|
function authenticate(req) {
|
||||||
|
const token = extractToken(req);
|
||||||
|
const identity = validateKey(token, { allowAnonymous: _authConfig.allow_anonymous });
|
||||||
|
if (identity === null) {
|
||||||
|
// Distinguish "no token presented" from "token presented but invalid/revoked".
|
||||||
|
// Both surface as 401 to the client (don't leak which case it was), but the
|
||||||
|
// server-side audit + log captures the source for operator diagnosis.
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
code: token ? 'invalid_or_revoked_key' : 'auth_required',
|
||||||
|
message: token
|
||||||
|
? 'OLP API key is invalid or has been revoked.'
|
||||||
|
: 'OLP API key required. Pass via "Authorization: Bearer <token>" or "x-api-key: <token>".',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
authContext: {
|
||||||
|
keyId: identity.id,
|
||||||
|
owner_tier: identity.owner_tier,
|
||||||
|
providers_enabled: identity.providers_enabled,
|
||||||
|
source: identity.source,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the given provider key is permitted for this identity.
|
||||||
|
* `providers_enabled: '*'` grants all; an array is a whitelist.
|
||||||
|
*
|
||||||
|
* @param {{ providers_enabled: string[]|'*' }} authContext
|
||||||
|
* @param {string} providerKey
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isProviderEnabled(authContext, providerKey) {
|
||||||
|
if (authContext.providers_enabled === '*') return true;
|
||||||
|
return Array.isArray(authContext.providers_enabled) && authContext.providers_enabled.includes(providerKey);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /health
|
* GET /health
|
||||||
* Returns server health including count of loaded providers and per-provider
|
* Returns server health including count of loaded providers and per-provider
|
||||||
* healthCheck() snapshots (ADR 0002 § Provider contract: healthCheck is used
|
* healthCheck() snapshots (ADR 0002 § Provider contract: healthCheck is used
|
||||||
* by startup and /health endpoint per ADR 0002 § Provider contract description).
|
* 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.
|
||||||
*/
|
*/
|
||||||
async function handleHealth(req, res) {
|
async function handleHealth(req, res) {
|
||||||
const enabled = loadedProviders.size;
|
const enabled = loadedProviders.size;
|
||||||
@@ -423,6 +527,46 @@ async function handleHealth(req, res) {
|
|||||||
* Empty case: if no providers are enabled, data: [] is returned naturally.
|
* Empty case: if no providers are enabled, data: [] is returned naturally.
|
||||||
*/
|
*/
|
||||||
function handleModels(req, res) {
|
function handleModels(req, res) {
|
||||||
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
// Audit + auth for /v1/models per Phase 2 / D45 (ADR 0007 § 7).
|
||||||
|
const auditCtx = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
key_id: ANONYMOUS_KEY_ID,
|
||||||
|
owner_tier: 'anonymous',
|
||||||
|
method: 'GET',
|
||||||
|
path: '/v1/models',
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
status_code: 0,
|
||||||
|
latency_ms: 0,
|
||||||
|
cache_status: null,
|
||||||
|
fallback_hops: 0,
|
||||||
|
tried_providers: [],
|
||||||
|
error_code: null,
|
||||||
|
ir_request_hash: null,
|
||||||
|
chain_id: null,
|
||||||
|
};
|
||||||
|
let _authedKeyId = null;
|
||||||
|
res.on('finish', () => {
|
||||||
|
auditCtx.status_code = res.statusCode;
|
||||||
|
auditCtx.latency_ms = Date.now() - startMs;
|
||||||
|
try { appendAuditEvent(auditCtx); } catch { /* best-effort */ }
|
||||||
|
if (_authedKeyId && _authedKeyId !== ANONYMOUS_KEY_ID) {
|
||||||
|
touchLastUsed(_authedKeyId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const authResult = authenticate(req);
|
||||||
|
if (!authResult.ok) {
|
||||||
|
auditCtx.error_code = authResult.code;
|
||||||
|
return sendError(res, authResult.status, authResult.message, authResult.code,
|
||||||
|
olpErrorHeaders({ startMs }));
|
||||||
|
}
|
||||||
|
auditCtx.key_id = authResult.authContext.keyId;
|
||||||
|
auditCtx.owner_tier = authResult.authContext.owner_tier;
|
||||||
|
_authedKeyId = authResult.authContext.keyId;
|
||||||
|
|
||||||
const data = [];
|
const data = [];
|
||||||
|
|
||||||
// Canonical entries first
|
// Canonical entries first
|
||||||
@@ -472,9 +616,64 @@ function handleModels(req, res) {
|
|||||||
async function handleChatCompletions(req, res) {
|
async function handleChatCompletions(req, res) {
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
// Audit context — fields populated as the request proceeds; § 8 schema.
|
||||||
|
// Fired on res.on('finish') below regardless of success / error path.
|
||||||
|
const auditCtx = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
key_id: ANONYMOUS_KEY_ID, // updated post-auth
|
||||||
|
owner_tier: 'anonymous', // updated post-auth
|
||||||
|
method: 'POST',
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
status_code: 0, // updated on finish
|
||||||
|
latency_ms: 0, // updated on finish
|
||||||
|
cache_status: null,
|
||||||
|
fallback_hops: 0,
|
||||||
|
tried_providers: [],
|
||||||
|
error_code: null,
|
||||||
|
ir_request_hash: null,
|
||||||
|
chain_id: null,
|
||||||
|
};
|
||||||
|
// Wire post-response audit + lazy last_used_at update once, at the top, so
|
||||||
|
// every code path below (401, 400, 500, 200, streaming) emits an audit row.
|
||||||
|
// touchLastUsed is best-effort and never throws (§ 6.3).
|
||||||
|
let _authedKeyId = null;
|
||||||
|
res.on('finish', () => {
|
||||||
|
auditCtx.status_code = res.statusCode;
|
||||||
|
auditCtx.latency_ms = Date.now() - startMs;
|
||||||
|
try { appendAuditEvent(auditCtx); } catch { /* audit is best-effort */ }
|
||||||
|
if (_authedKeyId && _authedKeyId !== ANONYMOUS_KEY_ID) {
|
||||||
|
// Anonymous + __env_owner__ have no on-disk manifest to touch; touchLastUsed
|
||||||
|
// itself early-returns for those identities (§ 6.3 wrapper). For
|
||||||
|
// filesystem-stored keys, fire-and-forget.
|
||||||
|
touchLastUsed(_authedKeyId).catch(() => { /* warned internally */ });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Authentication (Phase 2 / D45, ADR 0007 § 5 + § 7) ───────────────────
|
||||||
|
// olpIdentity carries the OLP-side identity (keyId / owner_tier /
|
||||||
|
// providers_enabled). It is SEPARATE from `authContext` which is the
|
||||||
|
// per-provider OAuth/credential artifact passed to provider.spawn().
|
||||||
|
// Provider plugins treat `authContext === null` as "fall back to your
|
||||||
|
// own credential discovery (env / keychain / file)" — that contract is
|
||||||
|
// preserved at D45. Per-provider per-key credential mapping is a Phase
|
||||||
|
// 3+ concern (ADR 0007 § 12 Out of scope; v0.1 spec § 4.5 anticipated).
|
||||||
|
const authResult = authenticate(req);
|
||||||
|
if (!authResult.ok) {
|
||||||
|
auditCtx.error_code = authResult.code;
|
||||||
|
return sendError(res, authResult.status, authResult.message, authResult.code,
|
||||||
|
olpErrorHeaders({ startMs }));
|
||||||
|
}
|
||||||
|
const olpIdentity = authResult.authContext;
|
||||||
|
auditCtx.key_id = olpIdentity.keyId;
|
||||||
|
auditCtx.owner_tier = olpIdentity.owner_tier;
|
||||||
|
_authedKeyId = olpIdentity.keyId;
|
||||||
|
|
||||||
// Require JSON content-type
|
// Require JSON content-type
|
||||||
const ct = req.headers['content-type'] ?? '';
|
const ct = req.headers['content-type'] ?? '';
|
||||||
if (!ct.includes('application/json')) {
|
if (!ct.includes('application/json')) {
|
||||||
|
auditCtx.error_code = 'invalid_content_type';
|
||||||
return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error',
|
return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error',
|
||||||
olpErrorHeaders({ startMs }));
|
olpErrorHeaders({ startMs }));
|
||||||
}
|
}
|
||||||
@@ -483,6 +682,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
try {
|
try {
|
||||||
body = await readJSON(req);
|
body = await readJSON(req);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
auditCtx.error_code = 'invalid_request_body';
|
||||||
return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error',
|
return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error',
|
||||||
olpErrorHeaders({ startMs }));
|
olpErrorHeaders({ startMs }));
|
||||||
}
|
}
|
||||||
@@ -493,22 +693,22 @@ async function handleChatCompletions(req, res) {
|
|||||||
ir = openAIToIR(body);
|
ir = openAIToIR(body);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof BadRequestError) {
|
if (e instanceof BadRequestError) {
|
||||||
|
auditCtx.error_code = 'invalid_ir';
|
||||||
return sendError(res, 400, e.message, 'invalid_request_error',
|
return sendError(res, 400, e.message, 'invalid_request_error',
|
||||||
olpErrorHeaders({ startMs }));
|
olpErrorHeaders({ startMs }));
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
auditCtx.model = ir.model;
|
||||||
// Auth context is null at D5/D9 — providers fall back to their own credential
|
|
||||||
// discovery (env var, keychain, credentials file). Phase 2 multi-key
|
|
||||||
// infrastructure will pass a real authContext carrying the per-key OLP token.
|
|
||||||
const authContext = null;
|
|
||||||
|
|
||||||
// ── Fallback engine: build chain (ADR 0004) ─────────────────────────────
|
// ── Fallback engine: build chain (ADR 0004) ─────────────────────────────
|
||||||
// buildDefaultChain returns null if no enabled provider serves this model.
|
// buildDefaultChain returns null if no enabled provider serves this model.
|
||||||
// Per ADR 0004 § D9: at v0.1, chain is single-hop (no fallback) unless the
|
// Per ADR 0004 § D9: at v0.1, chain is single-hop (no fallback) unless the
|
||||||
// user has populated ~/.olp/config.json routing.chains.
|
// user has populated ~/.olp/config.json routing.chains.
|
||||||
const chain = buildDefaultChain(
|
//
|
||||||
|
// `let` (not `const`) because the chain may be filtered below per the
|
||||||
|
// authenticated key's providers_enabled allowlist (ADR 0007 § 10 #11).
|
||||||
|
let chain = buildDefaultChain(
|
||||||
ir.model,
|
ir.model,
|
||||||
loadedProviders,
|
loadedProviders,
|
||||||
_fallbackConfig.chains,
|
_fallbackConfig.chains,
|
||||||
@@ -517,6 +717,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
if (!chain) {
|
if (!chain) {
|
||||||
// ALIGNMENT.md: 0 Enabled Providers at v0.1 → 503 per spec
|
// ALIGNMENT.md: 0 Enabled Providers at v0.1 → 503 per spec
|
||||||
|
auditCtx.error_code = 'no_enabled_provider';
|
||||||
return sendError(
|
return sendError(
|
||||||
res, 503,
|
res, 503,
|
||||||
`No enabled providers for model ${ir.model}. See README § Supported Providers.`,
|
`No enabled providers for model ${ir.model}. See README § Supported Providers.`,
|
||||||
@@ -525,12 +726,39 @@ async function handleChatCompletions(req, res) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── providers_enabled scope enforcement (Phase 2 / D45, ADR 0007 § 10 #11) ─
|
||||||
|
// Filter the chain to providers this key is authorized for. If the resulting
|
||||||
|
// chain is empty, return 403 — the model exists in the registry but no hop
|
||||||
|
// is reachable from this identity's allowlist.
|
||||||
|
const _originalChainProviders = chain.map(hop => hop.provider);
|
||||||
|
chain = chain.filter(hop => isProviderEnabled(olpIdentity, hop.provider));
|
||||||
|
if (chain.length === 0) {
|
||||||
|
auditCtx.error_code = 'key_no_provider_access';
|
||||||
|
auditCtx.tried_providers = _originalChainProviders;
|
||||||
|
const allowed = olpIdentity.providers_enabled === '*' ? '*' : (olpIdentity.providers_enabled ?? []).join(', ') || '(none)';
|
||||||
|
return sendError(
|
||||||
|
res, 403,
|
||||||
|
`This OLP key does not have access to any provider serving model "${ir.model}". Key's providers_enabled: [${allowed}]. Chain providers: [${_originalChainProviders.join(', ')}].`,
|
||||||
|
'key_no_provider_access',
|
||||||
|
olpErrorHeaders({ startMs, model: ir.model }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth context is null at D45 — providers fall back to their own credential
|
||||||
|
// discovery (env var, keychain, credentials file). The OLP-side identity
|
||||||
|
// (olpIdentity above) is consumed for cache namespacing + providers_enabled
|
||||||
|
// gating + audit attribution. Per-provider per-key credential mapping is a
|
||||||
|
// Phase 3+ deferral (ADR 0007 § 12).
|
||||||
|
const authContext = null;
|
||||||
|
|
||||||
const requestId = generateRequestId();
|
const requestId = generateRequestId();
|
||||||
|
|
||||||
// ── Cache layer (ADR 0005) ──────────────────────────────────────────────
|
// ── Cache layer (ADR 0005, namespaced per OLP key per ADR 0007 § 7) ─────
|
||||||
// keyId: '__anonymous__' at D5/D9. Phase 2 multi-key infrastructure wires the
|
// keyId is the authenticated identity's namespace token. For anonymous
|
||||||
// real OLP API key ID here for D1 per-key isolation.
|
// (when auth.allow_anonymous=true) this is the legacy '__anonymous__'
|
||||||
const keyId = '__anonymous__';
|
// shared namespace; for filesystem keys it is the per-key id; for the
|
||||||
|
// OLP_OWNER_TOKEN env override it is the synthetic '__env_owner__'.
|
||||||
|
const keyId = olpIdentity.keyId;
|
||||||
|
|
||||||
// D2 bypass (per-hop, per ADR 0005 § D2):
|
// D2 bypass (per-hop, per ADR 0005 § D2):
|
||||||
// cache_control markers bypass OLP's response cache ONLY when the active hop
|
// cache_control markers bypass OLP's response cache ONLY when the active hop
|
||||||
@@ -829,11 +1057,21 @@ async function handleChatCompletions(req, res) {
|
|||||||
if (!streamPlugin) {
|
if (!streamPlugin) {
|
||||||
// Provider disappeared between chain build and here (edge case).
|
// Provider disappeared between chain build and here (edge case).
|
||||||
// Release the slot we acquired above so the counter stays balanced.
|
// Release the slot we acquired above so the counter stays balanced.
|
||||||
|
auditCtx.provider = streamProvider;
|
||||||
|
auditCtx.error_code = 'no_enabled_provider';
|
||||||
releaseSpawn(streamProvider);
|
releaseSpawn(streamProvider);
|
||||||
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider',
|
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider',
|
||||||
olpErrorHeaders({ startMs, model: ir.model }));
|
olpErrorHeaders({ startMs, model: ir.model }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D45 fold-in P1: populate audit ctx for the real-streaming path. Each
|
||||||
|
// exit below (success, error-after-first-chunk, pre-first-chunk-error,
|
||||||
|
// 503 above) leaves these fields representing the streaming attempt.
|
||||||
|
// Error paths amend `error_code`; success leaves it null.
|
||||||
|
auditCtx.provider = streamProvider;
|
||||||
|
auditCtx.tried_providers = [streamProvider];
|
||||||
|
auditCtx.cache_status = 'miss';
|
||||||
|
|
||||||
const streamHeaders = olpHeaders({
|
const streamHeaders = olpHeaders({
|
||||||
providerUsed: streamProvider,
|
providerUsed: streamProvider,
|
||||||
modelUsed: streamModel,
|
modelUsed: streamModel,
|
||||||
@@ -861,12 +1099,15 @@ async function handleChatCompletions(req, res) {
|
|||||||
model: streamModel,
|
model: streamModel,
|
||||||
error: irChunk.error,
|
error: irChunk.error,
|
||||||
});
|
});
|
||||||
|
auditCtx.error_code = 'streaming_error_after_first_chunk';
|
||||||
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
||||||
res.write(SSE_DONE);
|
res.write(SSE_DONE);
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No bytes written yet — throw to surface a clean error.
|
// No bytes written yet — throw to surface a clean error.
|
||||||
|
// auditCtx.error_code is set by the downstream catch handler (the
|
||||||
|
// outer streaming-path catch block fills it from the thrown error).
|
||||||
throw new ProviderError(irChunk.error ?? 'Provider emitted error chunk', 'SPAWN_FAILED');
|
throw new ProviderError(irChunk.error ?? 'Provider emitted error chunk', 'SPAWN_FAILED');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,6 +1213,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
model: streamModel,
|
model: streamModel,
|
||||||
error: e.message,
|
error: e.message,
|
||||||
});
|
});
|
||||||
|
auditCtx.error_code = e?.code ?? 'provider_error';
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
|
sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
|
||||||
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
|
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
|
||||||
@@ -1059,6 +1301,18 @@ async function handleChatCompletions(req, res) {
|
|||||||
fallbackHops: fallbackHops ?? 0,
|
fallbackHops: fallbackHops ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Audit ctx capture for chain-exhausted / provider-error path (audit fires
|
||||||
|
// on res.on('finish'); fields populated here so the row reflects what we
|
||||||
|
// know at exhaustion time — provider is the chain[0] primary per ADR 0004
|
||||||
|
// step 4, tried_providers is the failed-hop trail from fallbackDetail).
|
||||||
|
auditCtx.provider = providerUsed ?? 'none';
|
||||||
|
auditCtx.fallback_hops = fallbackHops ?? 0;
|
||||||
|
auditCtx.tried_providers = Array.isArray(fallbackDetail)
|
||||||
|
? fallbackDetail.map(t => t?.provider).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
auditCtx.cache_status = 'miss';
|
||||||
|
auditCtx.error_code = originalError?.code ?? 'provider_error';
|
||||||
|
|
||||||
// Send error with standard OLP headers + optional exhausted header +
|
// Send error with standard OLP headers + optional exhausted header +
|
||||||
// D40 X-OLP-Fallback-Detail (when any hop attempted to spawn failed).
|
// D40 X-OLP-Fallback-Detail (when any hop attempted to spawn failed).
|
||||||
// D40 (issue #7) — ungated v0.1 per maintainer decision; owner-vs-non-owner
|
// D40 (issue #7) — ungated v0.1 per maintainer decision; owner-vs-non-owner
|
||||||
@@ -1103,6 +1357,13 @@ async function handleChatCompletions(req, res) {
|
|||||||
fallbackDetail,
|
fallbackDetail,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Audit ctx capture for success path (audit fires on res.on('finish');
|
||||||
|
// status_code + latency_ms populated then).
|
||||||
|
auditCtx.provider = providerUsed;
|
||||||
|
auditCtx.fallback_hops = fallbackHops;
|
||||||
|
auditCtx.tried_providers = (Array.isArray(fallbackDetail) ? fallbackDetail.map(t => t?.provider).filter(Boolean) : []).concat([providerUsed]);
|
||||||
|
auditCtx.cache_status = cacheStatus;
|
||||||
|
|
||||||
if (ir.stream) {
|
if (ir.stream) {
|
||||||
// Streaming response path: burst replay from buffered chunks.
|
// Streaming response path: burst replay from buffered chunks.
|
||||||
// Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1.
|
// Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1.
|
||||||
|
|||||||
+500
-1
@@ -13,10 +13,47 @@ import { describe, it, before, after } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { request as httpRequest } from 'node:http';
|
import { request as httpRequest } from 'node:http';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { homedir } from 'node:os';
|
import { homedir, tmpdir as _tmpdirForSetup } from 'node:os';
|
||||||
|
import { mkdtempSync as _mkdtempSyncForSetup } from 'node:fs';
|
||||||
|
import { join as _pathJoinForSetup } from 'node:path';
|
||||||
import { computeCacheKey, extractCacheControlMarkers, hasCacheControl } from './lib/cache/keys.mjs';
|
import { computeCacheKey, extractCacheControlMarkers, hasCacheControl } from './lib/cache/keys.mjs';
|
||||||
import { CacheStore } from './lib/cache/store.mjs';
|
import { CacheStore } from './lib/cache/store.mjs';
|
||||||
|
|
||||||
|
// ── Phase 2 / D45 test-mode setup ─────────────────────────────────────────
|
||||||
|
// Two adjustments keep pre-D45 tests working alongside the new auth gate:
|
||||||
|
// 1. process.env.OLP_HOME → tmpdir so audit ndjson / manifest writes
|
||||||
|
// triggered by handleChatCompletions / handleModels do not pollute
|
||||||
|
// the user's real ~/.olp/. lib/keys.mjs + lib/audit.mjs resolve the
|
||||||
|
// env per-call so this takes effect immediately.
|
||||||
|
// 2. server.mjs __setAuthConfig({ allow_anonymous: true }) so existing
|
||||||
|
// HTTP integration tests (Suite 18 etc.) that hit /v1/chat/completions
|
||||||
|
// and /v1/models without an Authorization header continue to pass
|
||||||
|
// via the anonymous identity. Suite 20 (D45 auth tests) explicitly
|
||||||
|
// overrides per-case to exercise allow_anonymous: false / valid key /
|
||||||
|
// revoked / env-owner / providers_enabled paths.
|
||||||
|
// (ESM imports are hoisted, so all module side effects — including
|
||||||
|
// server.mjs's startup loadAuthConfigSync() — complete before this body
|
||||||
|
// code runs. Setting OLP_HOME + __setAuthConfig here applies to all
|
||||||
|
// suites below.)
|
||||||
|
const _GLOBAL_TEST_OLP_HOME = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-home-'));
|
||||||
|
process.env.OLP_HOME = _GLOBAL_TEST_OLP_HOME;
|
||||||
|
// Clean up the global test tmpdir on process exit so successive npm test runs
|
||||||
|
// don't accumulate /var/folders/.../olp-test-home-* directories. process.on
|
||||||
|
// ('exit') fires synchronously after node:test reports all results.
|
||||||
|
process.on('exit', () => {
|
||||||
|
try {
|
||||||
|
// rmSync is imported lower in the file (Suite 19 imports it from 'node:fs').
|
||||||
|
// ESM hoists all imports to top-of-module so the binding is available here.
|
||||||
|
rmSync(_GLOBAL_TEST_OLP_HOME, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort; do not throw at exit
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// __setAuthConfig is imported below from './server.mjs'; deferring the call
|
||||||
|
// to a later block (after server.mjs's import-time loadAuthConfigSync runs)
|
||||||
|
// is necessary because ESM hoists imports before this body code. See the
|
||||||
|
// "Phase 2 / D45 server-side default override" block below the imports.
|
||||||
|
|
||||||
// ── Modules under test ────────────────────────────────────────────────────
|
// ── Modules under test ────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { validateIRRequest, validateIRMessage, VALID_ROLES, IR_VERSION } from './lib/ir/types.mjs';
|
import { validateIRRequest, validateIRMessage, VALID_ROLES, IR_VERSION } from './lib/ir/types.mjs';
|
||||||
@@ -4302,8 +4339,19 @@ import {
|
|||||||
__setFallbackConfig,
|
__setFallbackConfig,
|
||||||
__resetFallbackConfig,
|
__resetFallbackConfig,
|
||||||
__clearCache,
|
__clearCache,
|
||||||
|
__setAuthConfig,
|
||||||
|
__resetAuthConfig,
|
||||||
} from './server.mjs';
|
} 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 });
|
||||||
|
|
||||||
// ── 13a: Trigger taxonomy ────────────────────────────────────────────────
|
// ── 13a: Trigger taxonomy ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Fallback engine — trigger taxonomy (D9)', () => {
|
describe('Fallback engine — trigger taxonomy (D9)', () => {
|
||||||
@@ -9865,3 +9913,454 @@ describe('Suite 19 — lib/keys.mjs multi-key auth (ADR 0007, D44)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Suite 20: server.mjs auth integration (D45, ADR 0007 §§ 5/6.2/7/9.4) ──
|
||||||
|
//
|
||||||
|
// HTTP-level tests for the Phase 2 D45 server-side wire-up:
|
||||||
|
// - auth.allow_anonymous false-by-default 401
|
||||||
|
// - Authorization Bearer / x-api-key header acceptance
|
||||||
|
// - revoked-key 401 (acceptance criterion #6 — full coverage with D45)
|
||||||
|
// - OLP_OWNER_TOKEN env override (acceptance criterion #10 — full coverage)
|
||||||
|
// - providers_enabled 403 scope enforcement (acceptance criterion #11)
|
||||||
|
// - per-key cache namespace isolation (acceptance criterion #1)
|
||||||
|
// - audit ndjson written with correct fields (acceptance criterion #8)
|
||||||
|
// - touchLastUsed wire updates last_used_at after a request
|
||||||
|
// - /v1/models gates auth the same way as /v1/chat/completions
|
||||||
|
|
||||||
|
import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'node:fs';
|
||||||
|
import { appendAuditEvent, __resetAuditDropCount } from './lib/audit.mjs';
|
||||||
|
|
||||||
|
describe('Suite 20 — server.mjs auth integration (D45, ADR 0007)', () => {
|
||||||
|
// Each describe block gets its own tmp OLP_HOME so audit + key writes are
|
||||||
|
// isolated and verifiable. Restore the global test default in after().
|
||||||
|
const GLOBAL_OLP_HOME = process.env.OLP_HOME;
|
||||||
|
|
||||||
|
// Each Suite 20 server requires the anthropic auth-check to pass before the
|
||||||
|
// mock spawn runs. lib/providers/anthropic.mjs _spawnAndStream checks for an
|
||||||
|
// OAuth token BEFORE calling the (mock) spawn — without a token the AUTH_MISSING
|
||||||
|
// pre-check fires and the request 502s before the mock can return chunks. Same
|
||||||
|
// pattern as Suite 9 cache HTTP tests (line ~2154 "test-fake-oauth-token-for-
|
||||||
|
// cache-tests"). CI Node 24 has no OAuth env; local dev machines may; CI was
|
||||||
|
// the trigger that caught this.
|
||||||
|
let _suite20SavedOAuth;
|
||||||
|
function ensureSuite20FakeOAuth() {
|
||||||
|
_suite20SavedOAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'suite20-fake-oauth-token';
|
||||||
|
}
|
||||||
|
function restoreSuite20OAuth() {
|
||||||
|
if (_suite20SavedOAuth !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = _suite20SavedOAuth;
|
||||||
|
else delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSuite20Server() {
|
||||||
|
__setProvidersEnabled({ anthropic: true });
|
||||||
|
__setSpawnImpl(makeMockSpawn(['suite20-mock-response']));
|
||||||
|
ensureSuite20FakeOAuth();
|
||||||
|
const server = createOlpServer();
|
||||||
|
return new Promise(resolve => {
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardownSuite20(server) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
__resetSpawnImpl();
|
||||||
|
__setProvidersEnabled({});
|
||||||
|
__clearCache();
|
||||||
|
restoreSuite20OAuth();
|
||||||
|
if (server) server.close(() => resolve());
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 20a-d: header parsing + happy paths ────────────────────────────────
|
||||||
|
|
||||||
|
describe('20a-d — header parsing + valid key paths', () => {
|
||||||
|
let TMP, server, port;
|
||||||
|
before(async () => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20ad-'));
|
||||||
|
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 });
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20a: allow_anonymous=false + no Authorization header → 401 auth_required', async () => {
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20a' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 401);
|
||||||
|
const err = JSON.parse(r.body);
|
||||||
|
assert.equal(err.error.type, 'auth_required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20b: valid Authorization: Bearer <token> → 200 (filesystem identity)', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '20b-bearer', owner_tier: 'guest', providers_enabled: ['anthropic'], 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: '20b' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20c: x-api-key header alternative → 200 (filesystem identity)', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '20c-xapikey', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
headers: { 'x-api-key': plaintext_token },
|
||||||
|
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20c' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20d: invalid token → 401 invalid_or_revoked_key', async () => {
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
headers: { Authorization: 'Bearer olp_not-a-real-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
|
||||||
|
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20d' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 401);
|
||||||
|
const err = JSON.parse(r.body);
|
||||||
|
assert.equal(err.error.type, 'invalid_or_revoked_key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 20e-g: revocation + env owner + allow_anonymous true ────────────────
|
||||||
|
|
||||||
|
describe('20e-g — revocation + env owner override + anonymous-mode dev', () => {
|
||||||
|
let TMP, server, port;
|
||||||
|
before(async () => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20eg-'));
|
||||||
|
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 });
|
||||||
|
delete process.env.ENV_OWNER_VAR;
|
||||||
|
delete process.env[ENV_OWNER_VAR];
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20e: revoked key → 401 invalid_or_revoked_key (criterion #6 full coverage)', async () => {
|
||||||
|
const { id, plaintext_token } = createKey({ name: '20e-rev', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
|
||||||
|
// First request — should succeed
|
||||||
|
const r1 = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20e-1' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r1.status, 200);
|
||||||
|
// Revoke
|
||||||
|
await revokeKey({ id, olpHome: TMP });
|
||||||
|
// Second request — must 401
|
||||||
|
const r2 = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20e-2' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r2.status, 401);
|
||||||
|
const err = JSON.parse(r2.body);
|
||||||
|
assert.equal(err.error.type, 'invalid_or_revoked_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20f: OLP_OWNER_TOKEN env override → 200 (env identity, criterion #10 full coverage)', async () => {
|
||||||
|
const envToken = 'olp_' + 'f'.repeat(43);
|
||||||
|
process.env[ENV_OWNER_VAR] = envToken;
|
||||||
|
try {
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
headers: { Authorization: `Bearer ${envToken}` },
|
||||||
|
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20f' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
} finally {
|
||||||
|
delete process.env[ENV_OWNER_VAR];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20g: allow_anonymous=true + no header → 200 (anonymous identity — dev escape hatch)', async () => {
|
||||||
|
__setAuthConfig({ allow_anonymous: true });
|
||||||
|
try {
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20g' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
} finally {
|
||||||
|
__setAuthConfig({ allow_anonymous: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 20h: providers_enabled scope enforcement (criterion #11) ─────────────
|
||||||
|
|
||||||
|
describe('20h — providers_enabled scope enforcement (criterion #11)', () => {
|
||||||
|
let TMP, server, port;
|
||||||
|
before(async () => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20h-'));
|
||||||
|
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 });
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20h: providers_enabled: ["mistral"] + anthropic-routed model → 403 key_no_provider_access', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '20h-scoped', owner_tier: 'guest', providers_enabled: ['mistral'], 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: '20h' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
const err = JSON.parse(r.body);
|
||||||
|
assert.equal(err.error.type, 'key_no_provider_access');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20h-extra: providers_enabled: "*" + anthropic-routed model → 200 (sanity baseline)', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '20h-wildcard', 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: '20h-star' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 20i: per-key cache namespace isolation (criterion #1) ────────────────
|
||||||
|
|
||||||
|
describe('20i — per-key cache namespace isolation (criterion #1)', () => {
|
||||||
|
let TMP, server, port;
|
||||||
|
before(async () => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20i-'));
|
||||||
|
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 });
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20i: keys A + B sending identical payload do NOT share cache (per-keyId namespace from ADR 0005 D1)', async () => {
|
||||||
|
const { plaintext_token: tokenA } = createKey({ name: '20i-A', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
|
||||||
|
const { plaintext_token: tokenB } = createKey({ name: '20i-B', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
|
||||||
|
const sharedPayload = {
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
messages: [{ role: 'user', content: '20i-cache-shared-prompt' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Key A: first request → miss
|
||||||
|
const rA1 = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
body: sharedPayload,
|
||||||
|
});
|
||||||
|
assert.equal(rA1.status, 200);
|
||||||
|
assert.equal(rA1.headers['x-olp-cache'], 'miss');
|
||||||
|
|
||||||
|
// Key A: second identical request → hit
|
||||||
|
const rA2 = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
body: sharedPayload,
|
||||||
|
});
|
||||||
|
assert.equal(rA2.status, 200);
|
||||||
|
assert.equal(rA2.headers['x-olp-cache'], 'hit', 'Key A second request must hit cache within A namespace');
|
||||||
|
|
||||||
|
// Key B: identical payload but different key → MUST be miss (isolated namespace)
|
||||||
|
const rB1 = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
headers: { Authorization: `Bearer ${tokenB}` },
|
||||||
|
body: sharedPayload,
|
||||||
|
});
|
||||||
|
assert.equal(rB1.status, 200);
|
||||||
|
assert.equal(rB1.headers['x-olp-cache'], 'miss',
|
||||||
|
'Key B first request MUST be miss — per-key cache isolation (ADR 0005 D1 + ADR 0007 § 7)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 20j: audit ndjson written with § 8 schema fields ─────────────────────
|
||||||
|
|
||||||
|
describe('20j — audit ndjson written per § 8 schema', () => {
|
||||||
|
let TMP, server, port;
|
||||||
|
before(async () => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20j-'));
|
||||||
|
process.env.OLP_HOME = TMP;
|
||||||
|
__setAuthConfig({ allow_anonymous: false });
|
||||||
|
__resetAuditDropCount();
|
||||||
|
({ server, port } = await makeSuite20Server());
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await teardownSuite20(server);
|
||||||
|
process.env.OLP_HOME = GLOBAL_OLP_HOME;
|
||||||
|
__setAuthConfig({ allow_anonymous: true });
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20j: successful request appends an audit row with key_id, model, status_code, latency_ms (criterion #8)', async () => {
|
||||||
|
const { id, plaintext_token } = createKey({ name: '20j-aud', owner_tier: 'guest', providers_enabled: ['anthropic'], 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: '20j' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
// Allow the res.on('finish') hook to flush the audit write
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 25));
|
||||||
|
|
||||||
|
const auditPath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
|
||||||
|
assert.ok(fsExistsSync(auditPath), 'audit.ndjson must be created on first request');
|
||||||
|
const lines = fsReadFileSync(auditPath, 'utf-8').trim().split('\n').filter(Boolean);
|
||||||
|
assert.ok(lines.length >= 1, 'at least one audit line expected');
|
||||||
|
const lastRow = JSON.parse(lines[lines.length - 1]);
|
||||||
|
|
||||||
|
assert.equal(lastRow.key_id, id);
|
||||||
|
assert.equal(lastRow.owner_tier, 'guest');
|
||||||
|
assert.equal(lastRow.method, 'POST');
|
||||||
|
assert.equal(lastRow.path, '/v1/chat/completions');
|
||||||
|
assert.equal(lastRow.model, 'claude-sonnet-4-6');
|
||||||
|
assert.equal(lastRow.status_code, 200);
|
||||||
|
assert.ok(typeof lastRow.latency_ms === 'number' && lastRow.latency_ms >= 0);
|
||||||
|
assert.ok(typeof lastRow.ts === 'string');
|
||||||
|
assert.equal(lastRow.provider, 'anthropic');
|
||||||
|
|
||||||
|
// PII guard: no message / response content in the audit row
|
||||||
|
assert.ok(!('content' in lastRow), 'no message content in audit row (§ 8 no PII)');
|
||||||
|
assert.ok(JSON.stringify(lastRow).indexOf('20j') === -1,
|
||||||
|
'request payload text must NOT appear in audit row (§ 8 no PII)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20j-stream: streaming-path audit row populates provider + cache_status (D45 fold-in P1 regression)', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '20j-stream', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}`, Accept: 'text/event-stream' },
|
||||||
|
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20j-stream' }], stream: true },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const auditPath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
|
||||||
|
const lines = fsReadFileSync(auditPath, 'utf-8').trim().split('\n').filter(Boolean);
|
||||||
|
const streamingRows = lines.map(l => JSON.parse(l)).filter(row =>
|
||||||
|
row.path === '/v1/chat/completions' && row.status_code === 200 && row.key_id !== ANONYMOUS_KEY_ID,
|
||||||
|
);
|
||||||
|
assert.ok(streamingRows.length >= 1, 'at least one successful authed row expected');
|
||||||
|
const lastStreamingRow = streamingRows[streamingRows.length - 1];
|
||||||
|
// Prior to D45 fold-in P1: provider was null on real-streaming success path.
|
||||||
|
assert.equal(lastStreamingRow.provider, 'anthropic',
|
||||||
|
'streaming-path audit row MUST populate provider (D45 fold-in P1 regression)');
|
||||||
|
assert.equal(lastStreamingRow.cache_status, 'miss',
|
||||||
|
'streaming-path audit row MUST populate cache_status');
|
||||||
|
assert.deepEqual(lastStreamingRow.tried_providers, ['anthropic']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20j-401: 401 unauth request still appends audit row with error_code', async () => {
|
||||||
|
const before = fsExistsSync(_pathJoinForSetup(TMP, 'logs', 'audit.ndjson'))
|
||||||
|
? fsReadFileSync(_pathJoinForSetup(TMP, 'logs', 'audit.ndjson'), 'utf-8').split('\n').filter(Boolean).length
|
||||||
|
: 0;
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'POST', path: '/v1/chat/completions',
|
||||||
|
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20j-401' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 401);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 25));
|
||||||
|
const after = fsReadFileSync(_pathJoinForSetup(TMP, 'logs', 'audit.ndjson'), 'utf-8').split('\n').filter(Boolean);
|
||||||
|
assert.ok(after.length > before, '401 must also append a row');
|
||||||
|
const lastRow = JSON.parse(after[after.length - 1]);
|
||||||
|
assert.equal(lastRow.status_code, 401);
|
||||||
|
assert.equal(lastRow.error_code, 'auth_required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 20k: touchLastUsed wire after successful request ────────────────────
|
||||||
|
|
||||||
|
describe('20k — touchLastUsed wire updates last_used_at post-request', () => {
|
||||||
|
let TMP, server, port;
|
||||||
|
before(async () => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20k-'));
|
||||||
|
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 });
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20k: filesystem key last_used_at populated after first successful request', async () => {
|
||||||
|
const { id, plaintext_token, manifest: m0 } = createKey({
|
||||||
|
name: '20k-touch', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP,
|
||||||
|
});
|
||||||
|
assert.equal(m0.last_used_at, null);
|
||||||
|
|
||||||
|
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: '20k' }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
// Allow the async touchLastUsed (fired in res.on('finish')) to settle
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const m1 = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.ok(m1.last_used_at !== null, 'last_used_at must be populated after first request');
|
||||||
|
assert.equal(m1.revoked_at, null, 'revoked_at unchanged');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 20l: /v1/models also enforces auth ───────────────────────────────────
|
||||||
|
|
||||||
|
describe('20l — /v1/models also enforces auth', () => {
|
||||||
|
let TMP, server, port;
|
||||||
|
before(async () => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20l-'));
|
||||||
|
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 });
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20l: /v1/models without auth → 401', async () => {
|
||||||
|
const r = await fetch({ port, method: 'GET', path: '/v1/models' });
|
||||||
|
assert.equal(r.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20l-200: /v1/models with valid Bearer → 200 + data array', async () => {
|
||||||
|
const { plaintext_token } = createKey({ name: '20l-ok', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP });
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'GET', path: '/v1/models',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
assert.equal(body.object, 'list');
|
||||||
|
assert.ok(Array.isArray(body.data));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user