diff --git a/AGENTS.md b/AGENTS.md index 5ae9744..6c51f8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index a3df35f..c311825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` first, then `x-api-key: `. + - `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). diff --git a/README.md b/README.md index 547060a..64ac346 100644 --- a/README.md +++ b/README.md @@ -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). --- diff --git a/lib/audit.mjs b/lib/audit.mjs new file mode 100644 index 0000000..ec23bf8 --- /dev/null +++ b/lib/audit.mjs @@ -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; +} diff --git a/lib/keys.mjs b/lib/keys.mjs index 175c8ee..8b5327d 100644 --- a/lib/keys.mjs +++ b/lib/keys.mjs @@ -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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** diff --git a/server.mjs b/server.mjs index e836c69..73abc84 100644 --- a/server.mjs +++ b/server.mjs @@ -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 ` first, then `x-api-key: `. + * 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 " or "x-api-key: ".', + }; + } + 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. diff --git a/test-features.mjs b/test-features.mjs index ca4ae28..3ede803 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -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 โ†’ 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)); + }); + }); +});