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
+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).