diff --git a/AGENTS.md b/AGENTS.md index dda81fb..1ceb81e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj - `bin/olp-keys.mjs` — keygen CLI bootstrap surface per ADR 0007 § 9.1. **✅ Shipped at D47.** Subcommands: `keygen [--owner|--name=X|--providers=csv|--force]`, `list [--owner-only|--include-revoked]`, `revoke --id=X`. Plaintext token printed once on keygen. Installed via `package.json bin` so `npx olp-keys ...` works (also `npm run olp-keys ...`). - `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).** **D52 will extend with daily rotation per ADR 0008 § 5.** - `lib/audit-query.mjs` — audit ndjson aggregate query layer per ADR 0008 § 4. **🟡 D49 — discoverAuditFiles + readAuditWindow + aggregateRequests + topFallbackChains + spendTrendDaily + cacheHitRateWindow shipped. Cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). PII guard: aggregate shapes never include message content. In-memory scan per request (ADR 0008 Lane 2 = A; SQLite hybrid deferred to ADR 0007 § 13 trigger).** -- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — not yet authored.** +- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **🟡 D50 — placeholder stub shipped at repo root + `/dashboard` route in server.mjs serves it owner-only_block. D51 lands the full multi-panel UI (quota + 24h request stats + 30d spend trend + top fallback chains) per ADR 0008 § 6.** - `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT. - `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change. - `docs/adr/` — Architecture Decision Records. Read the index in `docs/adr/README.md` before proposing governance, SPOT, or contract changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2d11d..85ffb2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this ## Unreleased +### D50 — `server.mjs` management endpoints (Phase 3 dashboard wire-up) + +Third Phase 3 D-day. Wires the D49 `lib/audit-query.mjs` aggregate query layer into 4 owner_only_block HTTP endpoints per ADR 0008 §§ 7-8. Ships a placeholder `dashboard.html` at repo root (D51 lands the full multi-panel UI). All endpoints follow the Phase 2 / D45 auth + audit + touchLastUsed pattern. + +- **4 new endpoints** (all owner_only_block per ADR 0008 § 8 — anonymous + guest + missing-key all → 401): + - `GET /dashboard` — serves `dashboard.html` (Content-Type text/html; charset=utf-8). D50 stub explains the state + lists backing endpoints; D51 replaces with full UI. + - `GET /v0/management/dashboard-data` — full aggregate per ADR 0008 § 7.2: `{ generated_at, window_24h (auditAggregateRequests), cache_hit_24h (auditCacheHitRateWindow), quota (per-provider provider.quotaStatus + error capture), spend_trend_30d (auditSpendTrendDaily — exactly 30 entries), top_fallback_chains_24h (auditTopFallbackChains limit 10), cache_stats (live cacheStore.stats()) }`. + - `GET /v0/management/quota` — quota subset only (subset of dashboard-data; useful for scripted monitoring). + - `GET /cache/stats` — live in-memory `cacheStore.stats()` shape (`{ hits, misses, size, inflightCount }` + `generated_at` wrapper). +- **`_runOwnerOnlyManagementEndpoint(req, res, method, path, inner)` helper** factors the common auth + audit ctx + owner-block + res.on('finish') wire. inner is async (req, res, olpIdentity, auditCtx) → returns void. Eliminates 4× boilerplate. +- **`owner_only_block` mode** (ADR 0008 § 8): authenticate → if not owner → 401 `owner_required`. Distinct from `owner_only_trim` (Phase 2 /health pattern). Anonymous identity (when `allow_anonymous: true`) reaches the handler and is 401'd by the owner check — verified by Suite 24c. +- **Provider quotaStatus error capture**: dashboard-data + quota endpoints catch per-provider throws and surface `{ provider, error, available: null }` so one bad provider doesn't fail the whole panel (ADR 0008 § 9 graceful degradation). +- **`dashboard.html` placeholder** (~50 lines at repo root): explains the D50 state, lists backing endpoints with curl example. Cached in memory at first /dashboard request (`_loadDashboardHtml` with module-scope `_dashboardHtmlCache`); falls back to an in-memory stub if the file is missing (e.g., test imports from non-repo cwd). +- **Audit on management endpoints** (ADR 0008 § 7.5): every management request appends an audit row including 401 paths (verified by Suite 24j). Touch wire skips anonymous + env-owner identities (matches Phase 2 pattern). +- **Router**: 4 new GET branches added between /v1/chat/completions and the 404 fallback. +- **Test surface (Suite 24, +11 tests — 571 → 582):** + - 24a-d: /dashboard owner_only_block (owner 200 / guest 401 / anonymous-with-allow_anonymous=true 401 / no-auth-with-allow_anonymous=false 401) + - 24e: dashboard-data owner → 200 JSON with all required ADR § 7.2 fields (asserts `spend_trend_30d.length === 30`) + - 24f: dashboard-data guest → 401 owner_required + - 24g: quota owner → 200 JSON with quota array + - 24h: cache/stats owner → 200 JSON with `{ hits, misses, size, inflightCount, generated_at }` + - 24h-401: cache/stats guest → 401 + - 24i: successful dashboard-data appends audit row with `status_code: 200` + `key_id` + `path: '/v0/management/dashboard-data'` + - 24j: 401 (guest blocked) dashboard-data appends audit row with `error_code: 'owner_required'` + `owner_tier: 'guest'` +- **Documentation:** AGENTS.md `lib/audit-query.mjs` D49 marker note added + new `dashboard.html` entry (D50 placeholder). +- **Test count:** 571 → 582 (+11 D50 tests in Suite 24). +- **Authority:** ADR 0008 § 7 (endpoints) + § 8 (owner_only_block mode) + § 9 (graceful degradation) + § 7.5 (audit on management endpoints); ADR 0007 § 7 (auth model reused); ADR 0002 § Provider contract (quotaStatus); ADR 0005 (cacheStore.stats); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant. + ### D49 — `lib/audit-query.mjs` audit aggregate query layer (Phase 3) Second Phase 3 D-day. Implements ADR 0008 § 4 query API. Pure in-memory ndjson scan; cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). No server.mjs integration in this D-day (D50 wires the consuming endpoints). diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..4a68d12 --- /dev/null +++ b/dashboard.html @@ -0,0 +1,54 @@ + + + + + +OLP Dashboard + + + + +

OLP Dashboard

+

+ D50 placeholder. The full dashboard UI lands at D51 per + ADR 0008 § 6. + The three management endpoints are already live and serving real data — you can + curl them with an owner-tier Bearer token to preview the JSON shapes D51 will render. +

+

Backing endpoints (live at D50)

+ +

Auth

+

+ All /dashboard and /v0/management/* + /cache/stats endpoints are + owner_only_block per ADR 0007 § 7 + ADR 0008 § 8. Non-owner identities + (including anonymous when allow_anonymous: true) receive 401. +

+

Example request

+
curl -H "Authorization: Bearer olp_..." http://localhost:3456/v0/management/dashboard-data
+

— shipped at D50 (Phase 3); replaced at D51.

+ + diff --git a/server.mjs b/server.mjs index 62949be..a6c34fd 100644 --- a/server.mjs +++ b/server.mjs @@ -57,6 +57,13 @@ import { ENV_OWNER_KEY_ID, } from './lib/keys.mjs'; import { appendAuditEvent } from './lib/audit.mjs'; +// Phase 3 / D50 — management endpoints consume the audit aggregate query layer. +import { + aggregateRequests as auditAggregateRequests, + topFallbackChains as auditTopFallbackChains, + spendTrendDaily as auditSpendTrendDaily, + cacheHitRateWindow as auditCacheHitRateWindow, +} from './lib/audit-query.mjs'; // ── Config ──────────────────────────────────────────────────────────────── @@ -1462,6 +1469,174 @@ async function handleChatCompletions(req, res) { } } +// ── Phase 3 / D50 — Management endpoints (owner_only_block per ADR 0008 §8) ── + +// Read dashboard.html once at startup; cached in memory thereafter. If the +// file is absent (e.g. test that imports server.mjs from a non-repo cwd), we +// still serve a minimal in-memory fallback. The file lives at the repo root. +let _dashboardHtmlCache = null; +function _loadDashboardHtml() { + if (_dashboardHtmlCache !== null) return _dashboardHtmlCache; + try { + const path = join(__dirname, 'dashboard.html'); + _dashboardHtmlCache = readFileSync(path, 'utf-8'); + } catch { + _dashboardHtmlCache = 'OLP Dashboard

dashboard.html not on disk; server has fallen back to this in-memory stub. Check the OLP installation.

'; + } + return _dashboardHtmlCache; +} + +/** + * Gated owner-only_block handler factory per ADR 0008 §8 + §7.5. Builds the + * audit ctx, runs auth, blocks non-owner with 401, wires res.on('finish') for + * audit + touchLastUsed, then calls inner(req, res, olpIdentity, auditCtx). + * + * inner should be async (or sync); errors propagate to the router's catch. + */ +async function _runOwnerOnlyManagementEndpoint(req, res, method, path, inner) { + const startMs = Date.now(); + const auditCtx = { + ts: new Date().toISOString(), + key_id: ANONYMOUS_KEY_ID, + owner_tier: 'anonymous', + method, + path, + 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 && _authedKeyId !== ENV_OWNER_KEY_ID) { + touchLastUsed(_authedKeyId).catch(() => {}); + } + }); + + // Step 1: authenticate. + 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; + + // Step 2: owner-only_block. ADR 0008 §8 says management endpoints REJECT + // non-owner identities outright (vs /health's trim model). Anonymous + + // guest both produce 401 here. + if (olpIdentity.owner_tier !== 'owner') { + auditCtx.error_code = 'owner_required'; + return sendError(res, 401, + 'OLP owner-tier identity required for this management endpoint.', + 'owner_required', + olpErrorHeaders({ startMs })); + } + + // Step 3: delegate. + return await inner(req, res, olpIdentity, auditCtx); +} + +/** + * GET /dashboard + * Serves dashboard.html to owner identities. Owner-only_block (per ADR 0008 §8). + * Cached in memory at startup; D51 ships the real multi-panel UI; D50 ships a + * placeholder. + */ +async function handleDashboard(req, res) { + return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/dashboard', + async (_req, res2, _identity, _auditCtx) => { + const html = _loadDashboardHtml(); + res2.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Length': Buffer.byteLength(html, 'utf-8'), + 'Cache-Control': 'no-cache', + }); + res2.end(html); + }); +} + +/** + * GET /v0/management/dashboard-data + * Full JSON aggregate per ADR 0008 § 7.2. The dashboard 30s poll consumes + * this. Owner-only_block. + */ +async function handleManagementDashboardData(req, res) { + return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/dashboard-data', + async (_req, res2, _identity, _auditCtx) => { + // Quota panel: collect quotaStatus from each loaded provider; null on + // throw or null return → "unavailable" indicator. + const quota = []; + for (const [name, provider] of loadedProviders) { + try { + const q = await provider.quotaStatus(null); + quota.push({ provider: name, ...(q ?? {}), available: q?.available ?? null }); + } catch (err) { + quota.push({ provider: name, error: err?.message ?? String(err), available: null }); + } + } + + const WINDOW_24H = 24 * 60 * 60 * 1000; + const payload = { + generated_at: new Date().toISOString(), + window_24h: auditAggregateRequests({ windowMs: WINDOW_24H, logEvent }), + cache_hit_24h: auditCacheHitRateWindow({ windowMs: WINDOW_24H, logEvent }), + quota, + spend_trend_30d: auditSpendTrendDaily({ days: 30, logEvent }), + top_fallback_chains_24h: auditTopFallbackChains({ windowMs: WINDOW_24H, limit: 10, logEvent }), + cache_stats: cacheStore.stats(), + }; + sendJSON(res2, 200, payload); + }); +} + +/** + * GET /v0/management/quota + * Per-provider quota snapshot only (subset of dashboard-data). Useful for + * scripted monitoring. Owner-only_block. + */ +async function handleManagementQuota(req, res) { + return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/quota', + async (_req, res2, _identity, _auditCtx) => { + const quota = []; + for (const [name, provider] of loadedProviders) { + try { + const q = await provider.quotaStatus(null); + quota.push({ provider: name, ...(q ?? {}), available: q?.available ?? null }); + } catch (err) { + quota.push({ provider: name, error: err?.message ?? String(err), available: null }); + } + } + sendJSON(res2, 200, { generated_at: new Date().toISOString(), quota }); + }); +} + +/** + * GET /cache/stats + * Live in-memory CacheStore stats. Owner-only_block. + * Per ADR 0008 § 7.4: returns the current cacheStore.stats() shape + * ({ hits, misses, size, inflightCount }). Per-(provider, model) breakdown + * is a Phase 4+ amendment trigger. + */ +async function handleCacheStats(req, res) { + return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/cache/stats', + async (_req, res2, _identity, _auditCtx) => { + sendJSON(res2, 200, { generated_at: new Date().toISOString(), ...cacheStore.stats() }); + }); +} + // ── Request router ──────────────────────────────────────────────────────── /** @@ -1487,6 +1662,20 @@ async function router(req, res) { return await handleChatCompletions(req, res); } + // Phase 3 / D50 — management endpoints (owner-only_block per ADR 0008 § 8) + if (method === 'GET' && path === '/dashboard') { + return await handleDashboard(req, res); + } + if (method === 'GET' && path === '/v0/management/dashboard-data') { + return await handleManagementDashboardData(req, res); + } + if (method === 'GET' && path === '/v0/management/quota') { + return await handleManagementQuota(req, res); + } + if (method === 'GET' && path === '/cache/stats') { + return await handleCacheStats(req, res); + } + // 404 for any unrecognised route sendError(res, 404, `Route ${method} ${path} not found`, 'not_found'); } catch (e) { diff --git a/test-features.mjs b/test-features.mjs index b08bb0a..37690d2 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -11344,3 +11344,260 @@ describe('Suite 23 — D49 lib/audit-query.mjs (Phase 3 audit aggregate query la }); }); }); + +// ── Suite 24: D50 management endpoints (Phase 3, ADR 0008 §§ 7-8) ───────── +// +// HTTP-level tests for the 4 owner_only_block endpoints: +// /dashboard, /v0/management/dashboard-data, /v0/management/quota, /cache/stats +// Each must 401 non-owner identities (including anonymous-when-allow_anonymous=true +// per ADR 0008 § 8) + serve owner identities with proper Content-Type and shape. + +describe('Suite 24 — D50 management endpoints (Phase 3, ADR 0008 §§ 7-8)', () => { + const GLOBAL_OLP_HOME = process.env.OLP_HOME; + + let _suite24SavedOAuth; + function ensureSuite24FakeOAuth() { + _suite24SavedOAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'suite24-fake-oauth-token'; + } + function restoreSuite24OAuth() { + if (_suite24SavedOAuth !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = _suite24SavedOAuth; + else delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + } + + function makeSuite24Server() { + __setProvidersEnabled({ anthropic: true }); + __setSpawnImpl(makeMockSpawn(['suite24-response'])); + ensureSuite24FakeOAuth(); + const server = createOlpServer(); + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port })); + }); + } + function teardownSuite24(server) { + return new Promise(resolve => { + __resetSpawnImpl(); + __setProvidersEnabled({}); + __clearCache(); + restoreSuite24OAuth(); + if (server) server.close(() => resolve()); + else resolve(); + }); + } + + describe('24a-d — /dashboard owner_only_block', () => { + let TMP, server, port; + before(async () => { + TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-24ad-')); + process.env.OLP_HOME = TMP; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + ({ server, port } = await makeSuite24Server()); + }); + after(async () => { + await teardownSuite24(server); + process.env.OLP_HOME = GLOBAL_OLP_HOME; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + rmSync(TMP, { recursive: true, force: true }); + }); + + it('24a: owner → 200 text/html with "OLP Dashboard"', async () => { + const { plaintext_token } = createKey({ name: '24a-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/dashboard', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 200); + assert.ok(r.headers['content-type'].startsWith('text/html')); + assert.match(r.body, /OLP Dashboard/i); + }); + + it('24b: guest → 401 owner_required', async () => { + const { plaintext_token } = createKey({ name: '24b-guest', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/dashboard', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 401); + const err = JSON.parse(r.body); + assert.equal(err.error.type, 'owner_required'); + }); + + it('24c: anonymous (allow_anonymous: true) → 401 owner_required (owner_only_block per ADR 0008 § 8)', async () => { + // allow_anonymous: true → no header → anonymous identity → STILL 401 + // because management endpoints are owner_only_block (not trim). + const r = await fetch({ port, method: 'GET', path: '/dashboard' }); + assert.equal(r.status, 401); + const err = JSON.parse(r.body); + assert.equal(err.error.type, 'owner_required'); + }); + + it('24d: allow_anonymous: false + no header → 401 auth_required (middleware path)', async () => { + __setAuthConfig({ allow_anonymous: false, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + try { + const r = await fetch({ port, method: 'GET', path: '/dashboard' }); + assert.equal(r.status, 401); + const err = JSON.parse(r.body); + assert.equal(err.error.type, 'auth_required'); + } finally { + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + } + }); + }); + + describe('24e-g — /v0/management/dashboard-data + /v0/management/quota', () => { + let TMP, server, port; + before(async () => { + TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-24eg-')); + process.env.OLP_HOME = TMP; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + ({ server, port } = await makeSuite24Server()); + }); + after(async () => { + await teardownSuite24(server); + process.env.OLP_HOME = GLOBAL_OLP_HOME; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + rmSync(TMP, { recursive: true, force: true }); + }); + + it('24e: owner GET /v0/management/dashboard-data → 200 JSON with required ADR 0008 § 7.2 fields', async () => { + const { plaintext_token } = createKey({ name: '24e-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/v0/management/dashboard-data', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.ok(typeof body.generated_at === 'string'); + assert.ok(typeof body.window_24h === 'object'); + assert.ok(typeof body.window_24h.request_count === 'number'); + assert.ok(typeof body.cache_hit_24h === 'object'); + assert.ok(Array.isArray(body.quota)); + assert.ok(Array.isArray(body.spend_trend_30d)); + assert.equal(body.spend_trend_30d.length, 30, 'spend_trend_30d must have 30 entries'); + assert.ok(Array.isArray(body.top_fallback_chains_24h)); + assert.ok(typeof body.cache_stats === 'object'); + }); + + it('24f: guest GET /v0/management/dashboard-data → 401 owner_required', async () => { + const { plaintext_token } = createKey({ name: '24f-guest', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/v0/management/dashboard-data', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 401); + assert.equal(JSON.parse(r.body).error.type, 'owner_required'); + }); + + it('24g: owner GET /v0/management/quota → 200 JSON with quota array', async () => { + const { plaintext_token } = createKey({ name: '24g-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/v0/management/quota', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.ok(typeof body.generated_at === 'string'); + assert.ok(Array.isArray(body.quota)); + // Each quota entry has at least a provider key (and possibly more fields). + for (const q of body.quota) { + assert.ok(typeof q.provider === 'string'); + } + }); + }); + + describe('24h — /cache/stats', () => { + let TMP, server, port; + before(async () => { + TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-24h-')); + process.env.OLP_HOME = TMP; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + ({ server, port } = await makeSuite24Server()); + }); + after(async () => { + await teardownSuite24(server); + process.env.OLP_HOME = GLOBAL_OLP_HOME; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + rmSync(TMP, { recursive: true, force: true }); + }); + + it('24h: owner GET /cache/stats → 200 JSON with hits/misses/size/inflightCount', async () => { + const { plaintext_token } = createKey({ name: '24h-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/cache/stats', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.ok(typeof body.generated_at === 'string'); + assert.ok(typeof body.hits === 'number'); + assert.ok(typeof body.misses === 'number'); + assert.ok(typeof body.size === 'number'); + assert.ok(typeof body.inflightCount === 'number'); + }); + + it('24h-401: guest GET /cache/stats → 401', async () => { + const { plaintext_token } = createKey({ name: '24h-guest', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/cache/stats', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 401); + }); + }); + + describe('24i-j — audit rows on management endpoints', () => { + let TMP, server, port; + before(async () => { + TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-24ij-')); + process.env.OLP_HOME = TMP; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + ({ server, port } = await makeSuite24Server()); + }); + after(async () => { + await teardownSuite24(server); + process.env.OLP_HOME = GLOBAL_OLP_HOME; + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + rmSync(TMP, { recursive: true, force: true }); + }); + + it('24i: successful /v0/management/dashboard-data appends audit row with path + status_code 200', async () => { + const { id, plaintext_token } = createKey({ name: '24i-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/v0/management/dashboard-data', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 200); + await new Promise(resolve => setTimeout(resolve, 25)); + + const auditPath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson'); + assert.ok(fsExistsSync(auditPath)); + const lines = fsReadFileSync(auditPath, 'utf-8').trim().split('\n').filter(Boolean); + const mgmtRow = lines.map(l => JSON.parse(l)).find(row => + row.path === '/v0/management/dashboard-data' && row.status_code === 200, + ); + assert.ok(mgmtRow, 'management dashboard-data audit row must be present'); + assert.equal(mgmtRow.key_id, id); + assert.equal(mgmtRow.owner_tier, 'owner'); + assert.equal(mgmtRow.method, 'GET'); + }); + + it('24j: 401 (guest blocked) /v0/management/dashboard-data appends audit row with error_code', async () => { + const { id, plaintext_token } = createKey({ name: '24j-guest', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP }); + const r = await fetch({ + port, method: 'GET', path: '/v0/management/dashboard-data', + headers: { Authorization: `Bearer ${plaintext_token}` }, + }); + assert.equal(r.status, 401); + await new Promise(resolve => setTimeout(resolve, 25)); + + const auditPath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson'); + const lines = fsReadFileSync(auditPath, 'utf-8').trim().split('\n').filter(Boolean); + const blockedRow = lines.map(l => JSON.parse(l)).find(row => + row.path === '/v0/management/dashboard-data' && row.status_code === 401 && row.key_id === id, + ); + assert.ok(blockedRow, 'management 401 audit row must be present'); + assert.equal(blockedRow.error_code, 'owner_required'); + assert.equal(blockedRow.owner_tier, 'guest'); + }); + }); +});