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:
dtzp555-max
2026-05-25 14:42:24 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent 40064955ab
commit 06f619120d
5 changed files with 412 additions and 22 deletions
+83 -10
View File
@@ -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');