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:
dtzp555-max
2026-05-25 16:02:47 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent 686794e316
commit f9f2eaa059
5 changed files with 529 additions and 1 deletions
+189
View File
@@ -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 = '<!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 ────────────────────────────────────────────────────────
/**
@@ -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) {