mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
feat+test+docs: D46 — owner-vs-guest gating for /health + X-OLP-Fallback-Detail (Phase 2) (#22)
* feat+test+docs: D46 — owner-vs-guest gating for /health + X-OLP-Fallback-Detail (Phase 2) Third Phase 2 implementation D-day. Closes ADR 0007 § 10 acceptance criteria #4 (/health payload trimming for non-owner) + #5 (X-OLP-Fallback-Detail emission gating per fallback_detail_header_policy). Phase 2 server surface now fully gated end-to-end; remaining D-days are keygen CLI surface (D47+) and Phase 2 close (v0.2.0, maintainer- triggered). server.mjs handleHealth identity-aware payload per § 7.1: - Auth gate at top — 401 for unauth + allow_anonymous=false; 200 with trimmed { ok, version } for non-owner; 200 with full payload for owner. - Trim controlled by _authConfig.owner_only_endpoints — operator removing /health from the list reverts to v0.1.1 full-payload-to- everyone (opt-out knob). - touchLastUsed fires on res.on('finish') for filesystem identities; no audit row on /health (high-volume monitoring; out of scope at Phase 2 per § 8). server.mjs withFallbackDetailHeader identity-aware emission per § 7.2: - New shouldEmitFallbackDetailHeader(olpIdentity) helper reads _authConfig.fallback_detail_header_policy: 'owner_only' (default) → emit only to owner 'all' → emit unconditionally (v0.1.1 opt-back-in) 'none' → suppress unconditionally - olpIdentity null on pre-auth paths → emit (preserves D40 v0.1.1 behaviour for pre-auth errors where identity is unknown). - withFallbackDetailHeader signature gains 3rd `olpIdentity` arg; both call sites in handleChatCompletions updated. Test surface — Suite 21, +9 tests; +1 in Suite 20 (20m); 515 → 524: 20m: /health with no auth + allow_anonymous=false → 401 (consistency with /v1/*) 21a-d: /health payload trimming (criterion #4): anonymous trimmed; guest trimmed; owner full; owner_only_endpoints: [] opts out 21e-h: X-OLP-Fallback-Detail emission gating (criterion #5): owner_only + guest → header absent owner_only + owner → header present + valid JSON 'all' + guest → header present (v0.1.1 opt-back) 'none' + owner → header absent (full suppression) Tests use 2-hop chain anthropic→openai with anthropic primary failing to produce non-empty fallbackDetail for header content. Test-mode setup updated: Global __setAuthConfig({ allow_anonymous: true }) extended to also pass owner_only_endpoints: [] + fallback_detail_header_policy: 'all' so pre-D46 tests (Suite 18, F5 /health tests, D40 fallback-detail tests, etc.) continue to pass; Suite 21 overrides per-case. DOCS: - AGENTS.md: lib/keys.mjs marker updated to reflect D46 ship; impl- status-note + shipped-set updated. - README.md: Implementation Status row + Known limitations "Multi-key auth" note rewritten to reflect D46 ship + remaining keygen CLI. - CHANGELOG.md: D46 entry under Unreleased per release_kit overlay. AUTHORITY: - ADR 0007 §§ 7.1 + 7.2 implementation contracts + § 10 criteria #4 + #5 covered. - ADR 0004 Amendment 5 (D40 — "Phase 2 will re-introduce owner-vs- non-owner gating when lib/keys.mjs lands"): this D-day fulfils the deferral. - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. - Standing autopilot grant (~/.cc-rules/memory/auto/ standing_autopilot_phase_2.md in cc-rules bf0ed9a). Verified: 524/524 pass via npm test (no regression in 515 pre-D46 tests; 9 new Suite 21 tests + 1 new Suite 20m test all green). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: D46 fold-in — opus reviewer P3 polish (constant import + comment tighten) Fresh-context opus reviewer (PR #22) returned APPROVE_WITH_MINOR with 2 P3 findings, both trivial polish. - server.mjs imports gain ENV_OWNER_KEY_ID from lib/keys.mjs (already used the namesake ANONYMOUS_KEY_ID import). handleHealth touchLastUsed guard now uses the imported constant for SPOT discipline. - handleHealth audit-deferral comment tightened: removed the "§ 8 schema doesn't mandate auditing" phrasing (overstates the ADR — § 8 doesn't enumerate paths); replaced with the operational rationale (high-volume noise, no observability value until Phase 3 Dashboard). No behaviour change. 524/524 tests pass (verified locally). Authority: PR #22 fresh-context opus reviewer findings. 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:
+83
-10
@@ -54,6 +54,7 @@ import {
|
||||
touchLastUsed,
|
||||
loadAuthConfigSync,
|
||||
ANONYMOUS_KEY_ID,
|
||||
ENV_OWNER_KEY_ID,
|
||||
} from './lib/keys.mjs';
|
||||
import { appendAuditEvent } from './lib/audit.mjs';
|
||||
|
||||
@@ -393,16 +394,42 @@ function jsonStringifyAscii(value) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges X-OLP-Fallback-Detail into a base header object when the per-hop
|
||||
* failure tuples are non-empty. Returns the base object unchanged otherwise.
|
||||
* Identity-aware gate for X-OLP-Fallback-Detail emission per ADR 0007 § 7.2.
|
||||
* Reads `_authConfig.fallback_detail_header_policy`:
|
||||
* - 'owner_only' (default) → emit only when olpIdentity.owner_tier === 'owner'
|
||||
* - 'all' → emit unconditionally (v0.1.1 behaviour, opt-in)
|
||||
* - 'none' → suppress unconditionally
|
||||
* When olpIdentity is null (early-error paths before auth completed),
|
||||
* defaults to 'all' → emit (preserves the v0.1.1 ungated behaviour for
|
||||
* pre-auth errors where we don't yet know identity).
|
||||
*
|
||||
* D40 (issue #7).
|
||||
* @param {{owner_tier?: string}|null|undefined} olpIdentity
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldEmitFallbackDetailHeader(olpIdentity) {
|
||||
const policy = _authConfig?.fallback_detail_header_policy ?? 'owner_only';
|
||||
if (policy === 'none') return false;
|
||||
if (policy === 'all') return true;
|
||||
// 'owner_only' — gate by identity tier
|
||||
if (!olpIdentity) return true; // pre-auth path: don't suppress diagnostic info
|
||||
return olpIdentity.owner_tier === 'owner';
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges X-OLP-Fallback-Detail into a base header object when the per-hop
|
||||
* failure tuples are non-empty AND the per-request identity is permitted
|
||||
* to see the header per the policy (ADR 0007 § 7.2). Returns the base
|
||||
* object unchanged otherwise.
|
||||
*
|
||||
* D40 (issue #7) — gating added at D46 per ADR 0004 Amendment 5 ratification.
|
||||
*
|
||||
* @param {Record<string,string>} baseHeaders
|
||||
* @param {Array<object>|null|undefined} fallbackDetail
|
||||
* @param {{owner_tier?: string}|null|undefined} olpIdentity - request identity; null on pre-auth paths
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
function withFallbackDetailHeader(baseHeaders, fallbackDetail) {
|
||||
function withFallbackDetailHeader(baseHeaders, fallbackDetail, olpIdentity) {
|
||||
if (!shouldEmitFallbackDetailHeader(olpIdentity)) return baseHeaders;
|
||||
const value = serializeFallbackDetailHeader(fallbackDetail);
|
||||
if (value === null) return baseHeaders;
|
||||
return { ...baseHeaders, 'X-OLP-Fallback-Detail': value };
|
||||
@@ -486,14 +513,59 @@ function isProviderEnabled(authContext, providerKey) {
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Returns server health including count of loaded providers and per-provider
|
||||
* healthCheck() snapshots (ADR 0002 § Provider contract: healthCheck is used
|
||||
* by startup and /health endpoint per ADR 0002 § Provider contract description).
|
||||
*
|
||||
* Phase 2 / D45: no auth gate at this endpoint yet. Owner-vs-guest /health
|
||||
* payload trimming per ADR 0007 § 7.1 lands at D46.
|
||||
* Phase 2 / D46 (ADR 0007 § 7.1): identity-aware payload.
|
||||
* - owner identity → full per-provider details (existing payload)
|
||||
* - guest / anonymous → trimmed { ok, version } only
|
||||
* - no auth, allow_anonymous=false → 401 (consistent with /v1/* routes)
|
||||
*
|
||||
* The trimming behavior is gated on `_authConfig.owner_only_endpoints` —
|
||||
* the entry `/health` lives there by default. Removing `/health` from
|
||||
* the list reverts to v0.1.1 full-payload-to-everyone behaviour.
|
||||
*
|
||||
* Authority: ADR 0007 § 7.1 (Identity-class table) + § 7.2 (owner_only_endpoints).
|
||||
* Closes acceptance criterion #4.
|
||||
*/
|
||||
async function handleHealth(req, res) {
|
||||
const startMs = Date.now();
|
||||
|
||||
// D46: audit on /health is intentionally NOT enabled at Phase 2.
|
||||
// /health is a high-volume monitoring endpoint; per-call audit rows would
|
||||
// generate operational noise that has no observability value until a
|
||||
// Phase 3+ Dashboard aggregates /health stats. Deferred to Phase 3.
|
||||
|
||||
const authResult = authenticate(req);
|
||||
if (!authResult.ok) {
|
||||
return sendError(res, authResult.status, authResult.message, authResult.code);
|
||||
}
|
||||
const olpIdentity = authResult.authContext;
|
||||
|
||||
// Per § 7.1: owner sees full payload; guest + anonymous see trimmed.
|
||||
// Per § 7.2: gating is opt-out via `owner_only_endpoints` config; if
|
||||
// `/health` is removed from the list, all identities see the full
|
||||
// payload (operators wanting v0.1.1 behaviour have this knob).
|
||||
const gatedEndpoints = Array.isArray(_authConfig?.owner_only_endpoints)
|
||||
? _authConfig.owner_only_endpoints
|
||||
: ['/health'];
|
||||
const isGated = gatedEndpoints.includes('/health');
|
||||
const isOwner = olpIdentity.owner_tier === 'owner';
|
||||
|
||||
// Touch last_used_at for filesystem identities post-response. The callee
|
||||
// also early-returns on ANONYMOUS / ENV_OWNER keyIds (lib/keys.mjs § 6.3
|
||||
// wrapper) — this guard is defense-in-depth + skip the async call entirely
|
||||
// for non-filesystem identities.
|
||||
if (olpIdentity.keyId !== ANONYMOUS_KEY_ID && olpIdentity.keyId !== ENV_OWNER_KEY_ID) {
|
||||
res.on('finish', () => {
|
||||
touchLastUsed(olpIdentity.keyId).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
if (isGated && !isOwner) {
|
||||
// Trimmed payload per § 7.1.
|
||||
return sendJSON(res, 200, { ok: true, version: VERSION });
|
||||
}
|
||||
|
||||
// Full payload (owner OR /health removed from owner_only_endpoints).
|
||||
const enabled = loadedProviders.size;
|
||||
const available = listAllProviderNames().length;
|
||||
const providerStatuses = {};
|
||||
@@ -1323,7 +1395,7 @@ async function handleChatCompletions(req, res) {
|
||||
type: 'provider_error',
|
||||
},
|
||||
});
|
||||
const detailHeader = withFallbackDetailHeader({}, fallbackDetail);
|
||||
const detailHeader = withFallbackDetailHeader({}, fallbackDetail, olpIdentity);
|
||||
res.writeHead(errStatus, {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
@@ -1355,6 +1427,7 @@ async function handleChatCompletions(req, res) {
|
||||
const headers = withFallbackDetailHeader(
|
||||
olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }),
|
||||
fallbackDetail,
|
||||
olpIdentity,
|
||||
);
|
||||
|
||||
// Audit ctx capture for success path (audit fires on res.on('finish');
|
||||
|
||||
Reference in New Issue
Block a user