mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +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:
+189
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user