mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
feat+test+docs: D50 — server.mjs management endpoints (Phase 3 dashboard wire-up) (#27)
* feat+test+docs: 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:
GET /dashboard
Serves dashboard.html (text/html). D50 stub explains state +
lists backing endpoints. D51 replaces with full UI.
GET /v0/management/dashboard-data
Full aggregate per § 7.2:
{ generated_at, window_24h, cache_hit_24h, quota,
spend_trend_30d, top_fallback_chains_24h, cache_stats }
GET /v0/management/quota
Quota subset only (per-provider provider.quotaStatus + error
capture per § 9 graceful degradation).
GET /cache/stats
Live in-memory cacheStore.stats() with generated_at wrapper.
HELPER:
_runOwnerOnlyManagementEndpoint(req, res, method, path, inner)
Factors common auth + audit ctx + owner-block + res.on('finish')
wire. inner is async (req, res, olpIdentity, auditCtx) → void.
Eliminates 4× boilerplate.
OWNER_ONLY_BLOCK MODE (ADR 0008 § 8 D48-fold-in):
authenticate → if owner_tier !== '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 (Suite 24c). Allow_anonymous: false + no
header → 401 auth_required at middleware (Suite 24d).
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 D50 state, lists backing endpoints with curl example.
Cached in memory at first /dashboard request via _loadDashboardHtml
with module-scope _dashboardHtmlCache; falls back to in-memory stub
if file missing (defensive for test imports from non-repo cwd).
AUDIT ON MANAGEMENT ENDPOINTS (ADR 0008 § 7.5):
Every management request appends audit row including 401 paths
(verified by Suite 24j). Touch wire skips anonymous + env-owner
identities (matches Phase 2 pattern).
TESTS — Suite 24, +11 (571 → 582):
24a-d: /dashboard owner_only_block matrix (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 § 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 shape
24h-401: cache/stats guest → 401
24i: successful dashboard-data appends audit row with status 200
+ key_id + correct path
24j: 401 (guest blocked) dashboard-data appends audit row with
error_code: 'owner_required' + owner_tier: 'guest'
DOCUMENTATION:
- AGENTS.md: dashboard.html new entry (D50 placeholder); lib/audit-
query.mjs marker note unchanged.
- CHANGELOG.md: D50 entry under Unreleased per release_kit overlay.
NOT IN D50 scope:
- Full dashboard UI (D51 — replaces dashboard.html with the real
4-panel layout + 30s poll JS)
- Daily audit rotation (D52)
- tried_providers schema fix (D53)
- Phase 3 close (D55; v0.3.0; maintainer-triggered)
Test count: 571 → 582 (+11). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 7 (endpoint definitions) + § 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 source of truth).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: this PR adds 4 new entry-surface endpoints
under owner-only_block gating + a new lib/audit-query consumer surface.
Per Rule 5: management endpoints are owner-only operational surface,
not OpenAI-spec-compatible — they exist outside the /v1/chat/completions
+ /v1/models spec scope. No provider plugin / IR change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: D50 fold-in — AGENTS.md dashboard.html duplicate (opus P3)
Fresh-context opus reviewer (PR #27) flagged a duplicate dashboard.html
entry: my D50 addition was added directly above a stale
"Planned (Phase 6) — not yet authored" line that should have been
removed. The file contradicted itself.
Fix: merge into single entry — keep the original line phrasing and
attach the D50 status update.
No code change, no test change.
Authority: PR #27 fresh-context opus reviewer P3 finding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ...`).
|
- `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.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).**
|
- `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.
|
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
|
||||||
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
||||||
- `docs/adr/` — Architecture Decision Records. Read the index in `docs/adr/README.md` before proposing governance, SPOT, or contract changes.
|
- `docs/adr/` — Architecture Decision Records. Read the index in `docs/adr/README.md` before proposing governance, SPOT, or contract changes.
|
||||||
|
|||||||
@@ -4,6 +4,34 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
|||||||
|
|
||||||
## Unreleased
|
## 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)
|
### 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).
|
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).
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
OLP Dashboard — Phase 3 / D50 PLACEHOLDER
|
||||||
|
-------------------------------------------
|
||||||
|
This file is a stub shipped at D50 to wire the /dashboard route. D51 lands
|
||||||
|
the real multi-panel dashboard per ADR 0008 §6 (quota / per-provider 24h
|
||||||
|
counts / 30d spend trend / top fallback chains).
|
||||||
|
|
||||||
|
At D50, /dashboard returns this placeholder for owner-tier identities;
|
||||||
|
non-owner / unauth identities receive 401 (owner_only_block per ADR 0008
|
||||||
|
§8). The 3 backing endpoints (/v0/management/dashboard-data,
|
||||||
|
/v0/management/quota, /cache/stats) are live + return real data; you can
|
||||||
|
curl them directly with an owner-tier Bearer token to see the JSON
|
||||||
|
shapes that D51 will render.
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OLP Dashboard</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 2rem; max-width: 720px; color: #222; }
|
||||||
|
h1 { margin-top: 0; }
|
||||||
|
code, pre { background: #f4f4f4; padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.95em; }
|
||||||
|
pre { padding: 0.6em 0.8em; overflow-x: auto; }
|
||||||
|
.stub { background: #fff8e1; border-left: 4px solid #ffb300; padding: 1em 1.2em; margin: 1em 0; }
|
||||||
|
ul { padding-left: 1.2em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>OLP Dashboard</h1>
|
||||||
|
<p class="stub">
|
||||||
|
<strong>D50 placeholder.</strong> The full dashboard UI lands at D51 per
|
||||||
|
<a href="https://github.com/dtzp555-max/olp/blob/main/docs/adr/0008-dashboard-and-audit-query.md">ADR 0008</a> § 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.
|
||||||
|
</p>
|
||||||
|
<h2>Backing endpoints (live at D50)</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>GET /v0/management/dashboard-data</code> — full aggregate (quota + 24h request stats + 30d spend trend + top fallback chains).</li>
|
||||||
|
<li><code>GET /v0/management/quota</code> — per-provider quota snapshot only.</li>
|
||||||
|
<li><code>GET /cache/stats</code> — live in-memory CacheStore stats.</li>
|
||||||
|
</ul>
|
||||||
|
<h2>Auth</h2>
|
||||||
|
<p>
|
||||||
|
All <code>/dashboard</code> and <code>/v0/management/*</code> + <code>/cache/stats</code> endpoints are
|
||||||
|
<code>owner_only_block</code> per ADR 0007 § 7 + ADR 0008 § 8. Non-owner identities
|
||||||
|
(including anonymous when <code>allow_anonymous: true</code>) receive <code>401</code>.
|
||||||
|
</p>
|
||||||
|
<h2>Example request</h2>
|
||||||
|
<pre>curl -H "Authorization: Bearer olp_..." http://localhost:3456/v0/management/dashboard-data</pre>
|
||||||
|
<p style="color: #666; font-size: 0.9em;">— shipped at D50 (Phase 3); replaced at D51.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+189
@@ -57,6 +57,13 @@ import {
|
|||||||
ENV_OWNER_KEY_ID,
|
ENV_OWNER_KEY_ID,
|
||||||
} from './lib/keys.mjs';
|
} from './lib/keys.mjs';
|
||||||
import { appendAuditEvent } from './lib/audit.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 ────────────────────────────────────────────────────────────────
|
// ── 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 = '<!DOCTYPE html><title>OLP Dashboard</title><p>dashboard.html not on disk; server has fallen back to this in-memory stub. Check the OLP installation.</p>';
|
||||||
|
}
|
||||||
|
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 ────────────────────────────────────────────────────────
|
// ── Request router ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1487,6 +1662,20 @@ async function router(req, res) {
|
|||||||
return await handleChatCompletions(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
|
// 404 for any unrecognised route
|
||||||
sendError(res, 404, `Route ${method} ${path} not found`, 'not_found');
|
sendError(res, 404, `Route ${method} ${path} not found`, 'not_found');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user