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
+301 -8
View File
@@ -4343,14 +4343,20 @@ import {
__resetAuthConfig,
} from './server.mjs';
// ── Phase 2 / D45 server-side default override ────────────────────────────
// Override the auth.allow_anonymous default (false in production) so that
// existing pre-D45 HTTP integration tests (Suite 18 etc.) that make /v1/*
// requests without an Authorization header continue to pass as anonymous.
// New Suite 20 tests explicitly call __setAuthConfig per-case to exercise
// the production-default-off path + valid key / revoked / providers_enabled
// scopes.
__setAuthConfig({ allow_anonymous: true });
// ── Phase 2 / D45+D46 server-side default override ────────────────────────
// Override the production-off defaults so that existing pre-D45 HTTP
// integration tests (Suite 18 etc.) continue to pass:
// - allow_anonymous: true → /v1/* requests without Authorization anonymous
// - owner_only_endpoints: [] → /health full payload (D46 trimming opt-out)
// - fallback_detail_header_policy: 'all' → X-OLP-Fallback-Detail emitted to
// all identities (D40 v0.1.1 behaviour)
// New Suite 20 (D45) + Suite 21 (D46) explicitly call __setAuthConfig per-case
// to exercise the production-default paths.
__setAuthConfig({
allow_anonymous: true,
owner_only_endpoints: [],
fallback_detail_header_policy: 'all',
});
// ── 13a: Trigger taxonomy ────────────────────────────────────────────────
@@ -10331,6 +10337,27 @@ describe('Suite 20 — server.mjs auth integration (D45, ADR 0007)', () => {
// ── 20l: /v1/models also enforces auth ───────────────────────────────────
describe('20m — /health with no auth + allow_anonymous=false → 401 (D46 consistent gating)', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20m-'));
process.env.OLP_HOME = TMP;
__setAuthConfig({ allow_anonymous: false });
({ server, port } = await makeSuite20Server());
});
after(async () => {
await teardownSuite20(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('20m: GET /health with no auth and allow_anonymous=false → 401', async () => {
const r = await fetch({ port, method: 'GET', path: '/health' });
assert.equal(r.status, 401);
});
});
describe('20l — /v1/models also enforces auth', () => {
let TMP, server, port;
before(async () => {
@@ -10364,3 +10391,269 @@ describe('Suite 20 — server.mjs auth integration (D45, ADR 0007)', () => {
});
});
});
// ── Suite 21: D46 owner-vs-guest gating (ADR 0007 § 7.1 + § 7.2) ──────────
//
// HTTP-level tests for:
// - /health payload trimming (criterion #4): owner sees full per-provider
// statuses; guest + anonymous see trimmed { ok, version }
// - X-OLP-Fallback-Detail emission gating (criterion #5): policy
// 'owner_only' (default) emits only to owner; 'all' emits to everyone;
// 'none' suppresses entirely
describe('Suite 21 — D46 owner-vs-guest gating (ADR 0007 §§ 7.1, 7.2)', () => {
const GLOBAL_OLP_HOME = process.env.OLP_HOME;
let _suite21SavedOAuth;
function ensureSuite21FakeOAuth() {
_suite21SavedOAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN;
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'suite21-fake-oauth-token';
}
function restoreSuite21OAuth() {
if (_suite21SavedOAuth !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = _suite21SavedOAuth;
else delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
}
function makeSuite21Server() {
__setProvidersEnabled({ anthropic: true });
__setSpawnImpl(makeMockSpawn(['suite21-response']));
ensureSuite21FakeOAuth();
const server = createOlpServer();
return new Promise(resolve => {
server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
});
}
function teardownSuite21(server) {
return new Promise(resolve => {
__resetSpawnImpl();
__setProvidersEnabled({});
__clearCache();
restoreSuite21OAuth();
if (server) server.close(() => resolve());
else resolve();
});
}
// ── 21a-d: /health payload trimming (criterion #4) ──────────────────────
describe('21a-d — /health payload trimming (criterion #4)', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-21ad-'));
process.env.OLP_HOME = TMP;
// Default-tight gating: /health is owner-only-endpoint
__setAuthConfig({
allow_anonymous: true,
owner_only_endpoints: ['/health'],
fallback_detail_header_policy: 'owner_only',
});
({ server, port } = await makeSuite21Server());
});
after(async () => {
await teardownSuite21(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('21a: anonymous /health (allow_anonymous=true) → trimmed { ok, version }', async () => {
const r = await fetch({ port, method: 'GET', path: '/health' });
assert.equal(r.status, 200);
const body = JSON.parse(r.body);
assert.equal(body.ok, true);
assert.equal(typeof body.version, 'string');
assert.ok(!('providers' in body), 'anonymous /health MUST NOT include providers field');
});
it('21b: guest /health → trimmed { ok, version }', async () => {
const { plaintext_token } = createKey({ name: '21b-guest', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP });
const r = await fetch({
port, method: 'GET', path: '/health',
headers: { Authorization: `Bearer ${plaintext_token}` },
});
assert.equal(r.status, 200);
const body = JSON.parse(r.body);
assert.equal(body.ok, true);
assert.ok(!('providers' in body), 'guest /health MUST NOT include providers field');
});
it('21c: owner /health → full payload with providers', async () => {
const { plaintext_token } = createKey({ name: '21c-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP });
const r = await fetch({
port, method: 'GET', path: '/health',
headers: { Authorization: `Bearer ${plaintext_token}` },
});
assert.equal(r.status, 200);
const body = JSON.parse(r.body);
assert.equal(body.ok, true);
assert.ok('providers' in body, 'owner /health MUST include providers field');
assert.equal(typeof body.providers, 'object');
assert.equal(typeof body.providers.enabled, 'number');
assert.ok('status' in body.providers, 'owner /health providers.status must be present');
});
it('21d: owner_only_endpoints config opt-out — empty list → guest gets full payload', async () => {
__setAuthConfig({
allow_anonymous: true,
owner_only_endpoints: [],
fallback_detail_header_policy: 'owner_only',
});
try {
const { plaintext_token } = createKey({ name: '21d-guest-optout', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP });
const r = await fetch({
port, method: 'GET', path: '/health',
headers: { Authorization: `Bearer ${plaintext_token}` },
});
assert.equal(r.status, 200);
const body = JSON.parse(r.body);
assert.ok('providers' in body, 'with owner_only_endpoints: [], guest /health MUST get full payload');
} finally {
__setAuthConfig({
allow_anonymous: true,
owner_only_endpoints: ['/health'],
fallback_detail_header_policy: 'owner_only',
});
}
});
});
// ── 21e-h: X-OLP-Fallback-Detail emission gating (criterion #5) ─────────
describe('21e-h — X-OLP-Fallback-Detail emission gating (criterion #5)', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-21eh-'));
process.env.OLP_HOME = TMP;
// We will swap fallback_detail_header_policy per-case via __setAuthConfig.
__setAuthConfig({
allow_anonymous: false,
owner_only_endpoints: [],
fallback_detail_header_policy: 'owner_only',
});
// Wire a 2-hop chain with anthropic primary failing + openai secondary
// succeeding so X-OLP-Fallback-Detail has content to emit.
__setProvidersEnabled({ anthropic: true, openai: true });
__setFallbackConfig({
chains: {
'claude-sonnet-4-6': [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
],
},
soft_triggers: {},
providersEnabled: { anthropic: true, openai: true },
});
// Mock: anthropic spawn fails (exit code 1); openai succeeds.
// Use a custom spawn impl that branches on the bin name to distinguish.
// Simpler: spy on both providers — for simplicity here we use the global
// anthropic spawn mock that returns exit 1, and a real-looking codex one.
// The easier path: use the mistral-codex paired spawn — but our existing
// makeMockSpawn doesn't distinguish providers. Pattern used in Suite 18k:
// an executeHopFn that itself fails primary. Here we re-use the global
// anthropic mock that fails so the chain advances and openai serves.
__setSpawnImpl(makeMockSpawn([], 1)); // anthropic spawn fails
// Codex provider has its own __setSpawnImpl pattern; mock it separately.
ensureSuite21FakeOAuth();
// Codex auth needs CODEX env or its own auth artifact. For tests:
process.env.OPENAI_CODEX_AUTH_PATH = '/dev/null'; // bypass to no-auth
const codexMod = await import('./lib/providers/codex.mjs');
codexMod.__setSpawnImpl?.(makeMockSpawn(['suite21-codex-served']));
const serverInst = createOlpServer();
await new Promise(resolve => {
serverInst.listen(0, '127.0.0.1', () => resolve());
});
server = serverInst;
port = serverInst.address().port;
});
after(async () => {
__resetSpawnImpl();
__setProvidersEnabled({});
__resetFallbackConfig();
__clearCache();
restoreSuite21OAuth();
delete process.env.OPENAI_CODEX_AUTH_PATH;
const codexMod = await import('./lib/providers/codex.mjs');
codexMod.__resetSpawnImpl?.();
await new Promise(resolve => server.close(() => resolve()));
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 });
});
// NOTE: 21e-h tests use a contract-level audit on the header alone.
// The header surfaces ONLY for non-empty fallbackDetail (D40) AND when
// the identity is permitted per fallback_detail_header_policy (D46).
// The 2-hop chain with primary-fail provides the non-empty trail.
it('21e: policy=owner_only + guest → X-OLP-Fallback-Detail header ABSENT', async () => {
__setAuthConfig({
allow_anonymous: false,
owner_only_endpoints: [],
fallback_detail_header_policy: 'owner_only',
});
const { plaintext_token } = createKey({ name: '21e-guest', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '21e' }] },
});
// success or 502 both valid here — what matters is whether the
// detail header is present. We just need to invoke fallback.
assert.ok(r.headers['x-olp-fallback-detail'] === undefined,
'guest identity MUST NOT receive X-OLP-Fallback-Detail under owner_only policy');
});
it('21f: policy=owner_only + owner → X-OLP-Fallback-Detail header PRESENT', async () => {
__setAuthConfig({
allow_anonymous: false,
owner_only_endpoints: [],
fallback_detail_header_policy: 'owner_only',
});
const { plaintext_token } = createKey({ name: '21f-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '21f' }] },
});
assert.ok(r.headers['x-olp-fallback-detail'] !== undefined,
'owner identity MUST receive X-OLP-Fallback-Detail when fallback chain has failures');
// Validate header is JSON-parseable per D40 contract
const parsed = JSON.parse(r.headers['x-olp-fallback-detail']);
assert.ok(Array.isArray(parsed));
});
it('21g: policy=all + guest → X-OLP-Fallback-Detail header PRESENT (v0.1.1 opt-back-in)', async () => {
__setAuthConfig({
allow_anonymous: false,
owner_only_endpoints: [],
fallback_detail_header_policy: 'all',
});
const { plaintext_token } = createKey({ name: '21g-guest-all', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '21g' }] },
});
assert.ok(r.headers['x-olp-fallback-detail'] !== undefined,
'policy=all MUST emit X-OLP-Fallback-Detail to guest identity (D46 opt-back-in to v0.1.1)');
});
it('21h: policy=none + owner → X-OLP-Fallback-Detail header ABSENT (full suppression)', async () => {
__setAuthConfig({
allow_anonymous: false,
owner_only_endpoints: [],
fallback_detail_header_policy: 'none',
});
const { plaintext_token } = createKey({ name: '21h-owner-none', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '21h' }] },
});
assert.ok(r.headers['x-olp-fallback-detail'] === undefined,
'policy=none MUST suppress X-OLP-Fallback-Detail even for owner identity');
});
});
});