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:
dtzp555-max
2026-05-25 14:28:45 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent 4b9916341b
commit 40064955ab
7 changed files with 989 additions and 16 deletions
+3 -2
View File
@@ -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/cache/` — content-addressed cache layer (per-key isolation, `cache_control` bypass, chunked stream replay, singleflight). Governed by ADR 0005.
- `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004.
- `lib/keys.mjs` — multi-key auth, per-key namespacing, 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.**
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
- `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.
- `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.
---
+37
View File
@@ -4,6 +4,43 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
## 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)
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).
+7 -2
View File
@@ -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 |
| `models-registry.json` | ✅ Shipped | SPOT for `(provider, model)` metadata |
| `test-features.mjs` | ✅ Shipped | Comprehensive test suite covering IR, cache, fallback, and integration paths (CI: `test.yml`) |
| `lib/keys.mjs` | 🟡 Phase 2 — core 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 |
| `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 |
@@ -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.
- **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).
---
+110
View File
@@ -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
View File
@@ -45,6 +45,20 @@ export const ENV_OWNER_KEY_ID = '__env_owner__';
export const ENV_OWNER_VAR = 'OLP_OWNER_TOKEN';
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 ────────────────────────────────────────────────────────
@@ -60,7 +74,7 @@ let _touchInterleaveHook = async () => {};
// ── 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 _keyDir(id, opts) { return join(_keysDir(opts), id); }
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 ───────────────────────────────────────────────────────
/**
+271 -10
View File
@@ -48,6 +48,14 @@ import {
buildDefaultChain,
loadFallbackConfigSync,
} 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 ────────────────────────────────────────────────────────────────
@@ -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 ─────────────────────────────────────────────────────
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
// ~/.olp/config.json has providers.enabled.X = true.
@@ -383,11 +410,88 @@ function withFallbackDetailHeader(baseHeaders, fallbackDetail) {
// ── 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
* Returns server health including count of loaded providers and per-provider
* healthCheck() snapshots (ADR 0002 § Provider contract: healthCheck is used
* by startup and /health endpoint per ADR 0002 § Provider contract description).
*
* Phase 2 / D45: no auth gate at this endpoint yet. Owner-vs-guest /health
* payload trimming per ADR 0007 § 7.1 lands at D46.
*/
async function handleHealth(req, res) {
const enabled = loadedProviders.size;
@@ -423,6 +527,46 @@ async function handleHealth(req, res) {
* Empty case: if no providers are enabled, data: [] is returned naturally.
*/
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 = [];
// Canonical entries first
@@ -472,9 +616,64 @@ function handleModels(req, res) {
async function handleChatCompletions(req, res) {
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
const ct = req.headers['content-type'] ?? '';
if (!ct.includes('application/json')) {
auditCtx.error_code = 'invalid_content_type';
return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error',
olpErrorHeaders({ startMs }));
}
@@ -483,6 +682,7 @@ async function handleChatCompletions(req, res) {
try {
body = await readJSON(req);
} catch (e) {
auditCtx.error_code = 'invalid_request_body';
return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error',
olpErrorHeaders({ startMs }));
}
@@ -493,22 +693,22 @@ async function handleChatCompletions(req, res) {
ir = openAIToIR(body);
} catch (e) {
if (e instanceof BadRequestError) {
auditCtx.error_code = 'invalid_ir';
return sendError(res, 400, e.message, 'invalid_request_error',
olpErrorHeaders({ startMs }));
}
throw e;
}
// 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;
auditCtx.model = ir.model;
// ── Fallback engine: build chain (ADR 0004) ─────────────────────────────
// 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
// 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,
loadedProviders,
_fallbackConfig.chains,
@@ -517,6 +717,7 @@ async function handleChatCompletions(req, res) {
if (!chain) {
// ALIGNMENT.md: 0 Enabled Providers at v0.1 → 503 per spec
auditCtx.error_code = 'no_enabled_provider';
return sendError(
res, 503,
`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();
// ── Cache layer (ADR 0005) ──────────────────────────────────────────────
// keyId: '__anonymous__' at D5/D9. Phase 2 multi-key infrastructure wires the
// real OLP API key ID here for D1 per-key isolation.
const keyId = '__anonymous__';
// ── Cache layer (ADR 0005, namespaced per OLP key per ADR 0007 § 7) ─────
// keyId is the authenticated identity's namespace token. For anonymous
// (when auth.allow_anonymous=true) this is the legacy '__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):
// cache_control markers bypass OLP's response cache ONLY when the active hop
@@ -829,11 +1057,21 @@ async function handleChatCompletions(req, res) {
if (!streamPlugin) {
// Provider disappeared between chain build and here (edge case).
// Release the slot we acquired above so the counter stays balanced.
auditCtx.provider = streamProvider;
auditCtx.error_code = 'no_enabled_provider';
releaseSpawn(streamProvider);
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider',
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({
providerUsed: streamProvider,
modelUsed: streamModel,
@@ -861,12 +1099,15 @@ async function handleChatCompletions(req, res) {
model: streamModel,
error: irChunk.error,
});
auditCtx.error_code = 'streaming_error_after_first_chunk';
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
res.write(SSE_DONE);
res.end();
return;
}
// 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');
}
@@ -972,6 +1213,7 @@ async function handleChatCompletions(req, res) {
model: streamModel,
error: e.message,
});
auditCtx.error_code = e?.code ?? 'provider_error';
if (!res.headersSent) {
sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
@@ -1059,6 +1301,18 @@ async function handleChatCompletions(req, res) {
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 +
// 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
@@ -1103,6 +1357,13 @@ async function handleChatCompletions(req, res) {
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) {
// Streaming response path: burst replay from buffered chunks.
// Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1.
+500 -1
View File
@@ -13,10 +13,47 @@ import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { request as httpRequest } from 'node:http';
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 { 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 ────────────────────────────────────────────────────
import { validateIRRequest, validateIRMessage, VALID_ROLES, IR_VERSION } from './lib/ir/types.mjs';
@@ -4302,8 +4339,19 @@ import {
__setFallbackConfig,
__resetFallbackConfig,
__clearCache,
__setAuthConfig,
__resetAuthConfig,
} from './server.mjs';
// ── Phase 2 / D45 server-side default override ────────────────────────────
// Override the auth.allow_anonymous default (false in production) so that
// existing pre-D45 HTTP integration tests (Suite 18 etc.) that make /v1/*
// requests without an Authorization header continue to pass as anonymous.
// New Suite 20 tests explicitly call __setAuthConfig per-case to exercise
// the production-default-off path + valid key / revoked / providers_enabled
// scopes.
__setAuthConfig({ allow_anonymous: true });
// ── 13a: Trigger taxonomy ────────────────────────────────────────────────
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));
});
});
});