Skip to content

Commit 99755b5

Browse files
os-zhuangclaude
andauthored
refactor(security): converge anonymous-deny into one shared function + source-enumerating ratchet (#2567 Phase 2) (#2987)
* refactor(security): converge anonymous-deny into one shared function + source-enumerating ratchet (#2567 Phase 2) Phase 1 gated every HTTP surface against the requireAuth posture, but each seam hand-rolled the same anonymous check. Phase 2 removes the duplication and pins the surfaces so a new ungated entry point fails CI. - New shouldDenyAnonymous in @objectstack/core (security/anonymous-deny.ts): the single anonymous-deny decision + shared 401 body/constants, mirroring the auth-gate.ts pattern. All five seams delegate to it — REST enforceAuth, dispatcher handleGraphQL/handleMetadata/handleAI, hono denyAnonymous. Pure refactor, no runtime behavior change; identity resolution and the dynamic exemptions (public-form grants, share-link tokens) are untouched. - discover() ratchet on the authz-conformance matrix: statically enumerates the data/meta/graphql HTTP entry points from source (curated per-file probes, control-plane excluded) and asserts each is classified by a covers key. A new route or a removed/stale covers now fails CI as UNCLASSIFIED/STALE. A negative test proves the ratchet bites. Guards the isAuthGateAllowlisted(undefined)===true trap: body-routed seams (GraphQL) pass no path, so the non-empty-path guard denies anonymous unconditionally rather than falling through to the control-plane allowlist. Verified: core security 114/114 (9 new), runtime requireauth gate 13/13, rest auth-gate 4/4, hono anonymous-deny 5/5, dogfood conformance 5/5 (ratchet bites) + anonymous-deny surfaces e2e 10/10, single-authz-resolver guard intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ShknNUYoSTspJLBiqorD8V * fix(hono): drop now-unused IHttpServer import (CodeQL) The anonymous-deny import edit touched the import line, surfacing a pre-existing unused `IHttpServer` import (referenced only in comments). Remove it to clear the CodeQL finding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ShknNUYoSTspJLBiqorD8V --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aead168 commit 99755b5

9 files changed

Lines changed: 357 additions & 56 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/runtime': patch
4+
'@objectstack/rest': patch
5+
'@objectstack/plugin-hono-server': patch
6+
---
7+
8+
refactor(security): converge the anonymous-deny decision into one shared function + a source-enumerating ratchet (#2567 Phase 2)
9+
10+
Phase 1 gated every HTTP surface (REST `/data`, dispatcher `/graphql` + `/meta`,
11+
raw-hono `/data`) against the secure-by-default `requireAuth` posture, but each
12+
seam hand-rolled the same `!userId && !isSystem → 401` check. Phase 2 removes
13+
that duplication and pins the surfaces so a new ungated entry point fails CI.
14+
15+
- **New `shouldDenyAnonymous` in `@objectstack/core`** (`security/anonymous-deny.ts`)
16+
— the single anonymous-deny decision + shared 401 body/constants, mirroring the
17+
`auth-gate.ts` pattern (pure function so the seams can never drift). All five
18+
seams — REST `enforceAuth`, dispatcher `handleGraphQL` / `handleMetadata` /
19+
`handleAI`, hono `denyAnonymous` — now delegate to it. **Pure refactor: no
20+
runtime behavior change** (verified by the unchanged Phase-1 handler + e2e
21+
proofs). Identity resolution and the dynamic exemptions (public-form grants,
22+
share-link tokens) are untouched — they run upstream and only ever hand the
23+
seam an already-resolved context.
24+
- **A `discover()` ratchet on the authz-conformance matrix** — it statically
25+
enumerates the data/meta/graphql HTTP entry points from source (curated
26+
per-file probes, control-plane routes excluded) and asserts each is classified
27+
by a matrix `covers` key. A new `/data`/`/meta`/`/graphql` route (or a
28+
removed/stale `covers`) now fails CI as UNCLASSIFIED / STALE, not in review. A
29+
companion negative test proves the ratchet bites.
30+
31+
A design trap is guarded: `isAuthGateAllowlisted(undefined)` returns `true`, so a
32+
body-routed seam (GraphQL, which has no request path) must pass no path — the
33+
shared function's non-empty-path guard denies anonymous unconditionally there,
34+
never falling through to the control-plane allowlist.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #2567 Phase 2 — the shared anonymous-deny decision. These lock the exact
4+
// contract every HTTP seam now delegates to, including the load-bearing
5+
// `undefined`-path trap (a naive allowlist call would reopen GraphQL).
6+
7+
import { describe, it, expect } from 'vitest';
8+
import { shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from './anonymous-deny.js';
9+
10+
describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567)', () => {
11+
it('no-op when requireAuth is off (demo / single-tenant)', () => {
12+
expect(shouldDenyAnonymous({ requireAuth: false })).toBe(false);
13+
expect(shouldDenyAnonymous({ requireAuth: undefined })).toBe(false);
14+
});
15+
16+
it('denies an anonymous caller under requireAuth', () => {
17+
expect(shouldDenyAnonymous({ requireAuth: true })).toBe(true);
18+
});
19+
20+
it('passes an authenticated caller', () => {
21+
expect(shouldDenyAnonymous({ requireAuth: true, userId: 'u1' })).toBe(false);
22+
});
23+
24+
it('passes an internal system context', () => {
25+
expect(shouldDenyAnonymous({ requireAuth: true, isSystem: true })).toBe(false);
26+
});
27+
28+
it('passes an OPTIONS preflight even when anonymous', () => {
29+
expect(shouldDenyAnonymous({ requireAuth: true, method: 'OPTIONS' })).toBe(false);
30+
expect(shouldDenyAnonymous({ requireAuth: true, method: 'options' })).toBe(false);
31+
});
32+
33+
it('exempts a real control-plane path (auth / health)', () => {
34+
expect(shouldDenyAnonymous({ requireAuth: true, path: '/api/v1/auth/login' })).toBe(false);
35+
expect(shouldDenyAnonymous({ requireAuth: true, path: '/api/v1/health' })).toBe(false);
36+
});
37+
38+
it('denies a real data path', () => {
39+
expect(shouldDenyAnonymous({ requireAuth: true, path: '/api/v1/data/sys_user' })).toBe(true);
40+
});
41+
42+
// The trap: isAuthGateAllowlisted(undefined) === true. A body-routed seam
43+
// (GraphQL) passes no path; it MUST still deny anonymous, not fall through to
44+
// the allowlist. Guards against silently reopening #2567.
45+
it('denies when path is undefined/empty (body-routed seam — GraphQL trap guard)', () => {
46+
expect(shouldDenyAnonymous({ requireAuth: true, path: undefined })).toBe(true);
47+
expect(shouldDenyAnonymous({ requireAuth: true, path: null })).toBe(true);
48+
expect(shouldDenyAnonymous({ requireAuth: true, path: '' })).toBe(true);
49+
});
50+
51+
it('exposes a stable 401 body + status for seams to return', () => {
52+
expect(ANONYMOUS_DENY_STATUS).toBe(401);
53+
expect(ANONYMOUS_DENY_BODY).toEqual({ error: 'unauthenticated', message: expect.any(String) });
54+
});
55+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #2567 — the single anonymous-deny decision, shared by every HTTP seam.
5+
*
6+
* ADR-0056 D2 made the platform deny anonymous callers by default
7+
* (`requireAuth`). Phase 1 gated each surface (REST `/data`, dispatcher
8+
* `/graphql` + `/meta`, raw-hono `/data`) but every seam hand-rolled the same
9+
* `!userId && !isSystem → 401` check. This centralises that DECISION into one
10+
* pure, tested function — the exact pattern {@link ./auth-gate.ts} established
11+
* for the ADR-0069 auth-policy gate: keeping the decision in one function means
12+
* the seams can never drift on who is denied.
13+
*
14+
* It deliberately does NOT own identity resolution or the dynamic exemptions
15+
* (public-form submission, share-link tokens): those run UPSTREAM and set the
16+
* execution context (a `userId`, or `isSystem`) before a seam calls this, so
17+
* this function only ever inspects the already-resolved context.
18+
*/
19+
20+
import { isAuthGateAllowlisted } from './auth-gate.js';
21+
22+
/** HTTP status every seam returns for an anonymous-denied request. */
23+
export const ANONYMOUS_DENY_STATUS = 401 as const;
24+
/** Stable machine code (mirrors the REST `enforceAuth` seam). */
25+
export const ANONYMOUS_DENY_CODE = 'unauthenticated' as const;
26+
/** Human-facing message. */
27+
export const ANONYMOUS_DENY_MESSAGE = 'Authentication is required to access this endpoint.';
28+
/** The single 401 body shape every seam returns: `{ error, message }`. */
29+
export const ANONYMOUS_DENY_BODY = {
30+
error: ANONYMOUS_DENY_CODE,
31+
message: ANONYMOUS_DENY_MESSAGE,
32+
} as const;
33+
34+
export interface AnonymousDenyInput {
35+
/** The `requireAuth` posture. Falsy ⇒ no-op (demo / single-tenant). */
36+
requireAuth: boolean | undefined;
37+
/** Resolved caller id, if any. */
38+
userId?: string | null;
39+
/** Internal system context (never set on inbound HTTP; cannot be forged). */
40+
isSystem?: boolean;
41+
/** HTTP method — `OPTIONS` (CORS preflight) always passes. */
42+
method?: string | null;
43+
/**
44+
* OPTIONAL request path. When a NON-EMPTY string, a control-plane path
45+
* (auth / health / ready / discovery — see {@link isAuthGateAllowlisted}) is
46+
* exempt. Body-routed seams (GraphQL) have no meaningful path and pass
47+
* `undefined`; see the guard below for why that is load-bearing.
48+
*/
49+
path?: string | null;
50+
}
51+
52+
/**
53+
* True when the request MUST be rejected with 401. The one decision every HTTP
54+
* seam shares.
55+
*/
56+
export function shouldDenyAnonymous(input: AnonymousDenyInput): boolean {
57+
if (!input.requireAuth) return false; // posture off
58+
if (typeof input.method === 'string' && input.method.toUpperCase() === 'OPTIONS') {
59+
return false; // CORS preflight
60+
}
61+
if (input.userId || input.isSystem) return false; // authenticated / system
62+
// Control-plane exemption — ONLY for a real, non-empty path.
63+
//
64+
// ⚠️ `isAuthGateAllowlisted(undefined)` returns `true` (it treats "no path"
65+
// as allow-listed for the auth-gate's purposes). A body-routed seam such as
66+
// GraphQL has no meaningful request path; if it passed `undefined` straight
67+
// through, the allowlist would exempt EVERY anonymous query and silently
68+
// reopen exactly the hole #2567 closes. The non-empty guard is mandatory.
69+
if (typeof input.path === 'string' && input.path.length > 0 && isAuthGateAllowlisted(input.path)) {
70+
return false;
71+
}
72+
return true;
73+
}

packages/core/src/security/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,5 +102,15 @@ export {
102102
} from './posture-ladder.js';
103103
export { isAuthGateAllowlisted, evaluateAuthGate, type AuthGate } from './auth-gate.js';
104104

105+
// #2567 — the single anonymous-deny decision shared by every HTTP seam.
106+
export {
107+
shouldDenyAnonymous,
108+
ANONYMOUS_DENY_BODY,
109+
ANONYMOUS_DENY_STATUS,
110+
ANONYMOUS_DENY_CODE,
111+
ANONYMOUS_DENY_MESSAGE,
112+
type AnonymousDenyInput,
113+
} from './anonymous-deny.js';
114+
105115
// ADR-0091 D1/D2 — grant validity windows, the shared resolution-time predicate.
106116
export { isGrantActive, isGrantExpired, type GrantValidityWindow } from './grant-validity.js';

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ export interface AuthzPrimitive {
2121
enforcement?: string;
2222
/** Dogfood proof filename in this directory (required for high-risk enforced). */
2323
proof?: string;
24+
/**
25+
* Ratchet keys this row accounts for (ADR-0060), matched against the test's
26+
* `discover()`. A discovered HTTP entry point with no covering row fails CI as
27+
* UNCLASSIFIED; a `covers` key no longer in source fails as STALE. See
28+
* authz-conformance.test.ts (#2567 anonymous-deny surface enumeration).
29+
*/
30+
covers?: string[];
2431
/** Why it is experimental/removed, or a roadmap pointer. */
2532
note?: string;
2633
}
@@ -55,15 +62,18 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
5562
// the same `requireAuth` gate; these rows pin every entry point so a new
5663
// ungated surface (or a silent regression) fails CI, not review.
5764
{ id: 'anonymous-deny-meta', summary: 'anonymous-deny on the metadata endpoints (#2567 surface 1)', state: 'enforced',
58-
enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all',
59-
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts' },
65+
enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth → shouldDenyAnonymous) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all',
66+
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
67+
covers: ['meta:rest-server.ts:registerMetadataEndpoints', 'meta:http-dispatcher.ts:handleMetadata'] },
6068
{ id: 'anonymous-deny-graphql', summary: 'anonymous-deny on the dispatcher GraphQL endpoint (#2567 surface 2)', state: 'enforced',
61-
enforcement: 'runtime/http-dispatcher.ts handleGraphQL (requireAuth gate, resolves identity for the direct /graphql route) + runtime/dispatcher-plugin.ts requireAuth default(true), mirroring rest-server.ts',
69+
enforcement: 'runtime/http-dispatcher.ts handleGraphQL (shouldDenyAnonymous, resolves identity for the direct /graphql route) + runtime/dispatcher-plugin.ts requireAuth default(true), mirroring rest-server.ts',
6270
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
71+
covers: ['graphql:http-dispatcher.ts:handleGraphQL', 'graphql:dispatcher-plugin.ts:POST /api/v1/graphql'],
6372
note: 'GraphQL reaches the same object data as /data through kernel.graphql, whose security middleware falls OPEN for an anonymous context. Unit-proven in runtime/http-dispatcher.requireauth.test.ts (GraphQL block); e2e on the platform default in the surfaces proof.' },
6473
{ id: 'anonymous-deny-hono-data', summary: 'anonymous-deny on the raw-hono standard /data routes (#2567 surface 3)', state: 'enforced',
65-
enforcement: 'plugin-hono-server/hono-plugin.ts denyAnonymous gate on the standard /data routes (requireAuth ?? true, mirroring rest-server.ts)',
74+
enforcement: 'plugin-hono-server/hono-plugin.ts denyAnonymous gate (shouldDenyAnonymous) on the standard /data routes (requireAuth ?? true, mirroring rest-server.ts)',
6675
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
76+
covers: ['data:hono-plugin.ts:POST /data/:object', 'data:hono-plugin.ts:GET /data/:object/:id', 'data:hono-plugin.ts:GET /data/:object'],
6777
note: 'These routes delegate straight to ObjectQL and were only shadowed when the REST plugin registered the same paths FIRST — so the posture depended on plugin registration order (a load-order change silently reopened it, no test failing). Gating each route makes the deny decision a property of this entry point too. Handler-level proof in plugin-hono-server/hono-anonymous-deny.test.ts.' },
6878
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
6979
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },

packages/dogfood/test/authz-conformance.test.ts

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,131 @@
55
// every shared invariant (valid state, enforced-has-site, experimental/removed-
66
// has-note, proof-file-exists, high-risk-has-proof). A new fail-open or a deleted
77
// proof breaks the build.
8+
//
9+
// #2567 Phase 2 — the anonymous-deny SURFACES are additionally pinned by the
10+
// `discover()` ratchet: this test STATICALLY enumerates the data/meta/graphql
11+
// HTTP entry points from source and asserts each is classified by a matrix row.
12+
// A new ungated `/data` route (or a removed/stale `covers` key) then fails CI as
13+
// UNCLASSIFIED / STALE — the surface can't silently regress.
814

915
import { describe, expect, it } from 'vitest';
1016
import { fileURLToPath } from 'node:url';
11-
import { dirname } from 'node:path';
17+
import { dirname, join } from 'node:path';
18+
import { readFileSync } from 'node:fs';
1219
import { checkLedger } from '@objectstack/verify';
13-
import { AUTHZ_CONFORMANCE } from './authz-conformance.matrix.js';
20+
import { AUTHZ_CONFORMANCE, type AuthzPrimitive } from './authz-conformance.matrix.js';
1421

1522
const HERE = dirname(fileURLToPath(import.meta.url));
23+
// packages/dogfood/test → repo root.
24+
const REPO_ROOT = join(HERE, '../../..');
25+
26+
// ── #2567 ratchet — static enumeration of anonymous-deny HTTP entry points ──
27+
//
28+
// A CURATED per-file probe table (not a blind repo grep): scoped to the four
29+
// source files and to data/meta/graphql segments only, so control-plane routes
30+
// (/health, /auth, /ready, /discovery) are never enumerated as data surfaces.
31+
// But each probe is pattern-based WITHIN its file, so a genuinely new `/data`
32+
// route (or a new graphql/meta handler) is auto-discovered → new key → a
33+
// missing `covers` fails CI. Keys are derived from source CONTENT (route
34+
// literals / handler names), never line numbers, so they don't churn on edits.
35+
const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray) => string }> = [
36+
// REST /meta umbrella registrar — one guarded registrar covers all ~17 routes.
37+
{
38+
file: 'packages/rest/src/rest-server.ts',
39+
re: /private\s+registerMetadataEndpoints\s*\(/g,
40+
key: () => 'meta:rest-server.ts:registerMetadataEndpoints',
41+
},
42+
// Dispatcher meta + graphql handlers — curated NAMES only (NOT handleAI /
43+
// handleData / handleSecurity, which are separate surfaces/rows).
44+
{
45+
file: 'packages/runtime/src/http-dispatcher.ts',
46+
re: /async\s+(handleMetadata|handleGraphQL)\s*\(/g,
47+
key: (m) => `${m[1] === 'handleGraphQL' ? 'graphql' : 'meta'}:http-dispatcher.ts:${m[1]}`,
48+
},
49+
// Dispatcher-plugin direct GraphQL route (other server.post routes are
50+
// control-plane / feature endpoints, deliberately not enumerated here).
51+
{
52+
file: 'packages/runtime/src/dispatcher-plugin.ts',
53+
re: /server\.post\(\s*`\$\{prefix\}\/graphql`/g,
54+
key: () => 'graphql:dispatcher-plugin.ts:POST /api/v1/graphql',
55+
},
56+
// Raw-hono standard /data routes — genuinely pattern-based: ANY new
57+
// `rawApp.<verb>(`${prefix}/data...`)` → a new key → CI fails until a row covers it.
58+
{
59+
file: 'packages/plugins/plugin-hono-server/src/hono-plugin.ts',
60+
re: /rawApp\.(get|post|put|patch|delete)\(\s*`\$\{prefix\}(\/data[^`]*)`/g,
61+
key: (m) => `data:hono-plugin.ts:${m[1].toUpperCase()} ${m[2]}`,
62+
},
63+
];
64+
65+
/** Statically enumerate the anonymous-deny HTTP entry points from source. */
66+
function discoverAnonymousDenySurfaces(): Set<string> {
67+
const found = new Set<string>();
68+
for (const probe of PROBES) {
69+
const src = readFileSync(join(REPO_ROOT, probe.file), 'utf8');
70+
// Fresh lastIndex per file (the RegExp is shared, `g`-flagged).
71+
probe.re.lastIndex = 0;
72+
let m: RegExpExecArray | null;
73+
while ((m = probe.re.exec(src)) !== null) found.add(probe.key(m));
74+
}
75+
return found;
76+
}
77+
78+
const HIGH_RISK = [
79+
'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile',
80+
// #2567 — every anonymous-deny HTTP surface is high-risk: it guards the
81+
// same object data as REST `/data` through a sibling entry point.
82+
'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data',
83+
];
1684

1785
describe('ADR-0056 D10 — authorization conformance matrix', () => {
18-
it('is a sound conformance ledger (ADR-0060 checkLedger)', () => {
86+
it('is a sound conformance ledger (ADR-0060 checkLedger) + the #2567 surface ratchet holds', () => {
1987
const problems = checkLedger(AUTHZ_CONFORMANCE, {
2088
proofRoot: HERE, // proofs are dogfood test files alongside this one
21-
highRisk: [
22-
'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile',
23-
// #2567 — every anonymous-deny HTTP surface is high-risk: it guards the
24-
// same object data as REST `/data` through a sibling entry point.
25-
'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data',
26-
],
89+
highRisk: HIGH_RISK,
90+
// The ratchet: every discovered data/meta/graphql entry point must be
91+
// classified by exactly one row's `covers`, and no `covers` key may be
92+
// stale (no longer in source).
93+
discover: () => discoverAnonymousDenySurfaces(),
2794
});
2895
expect(problems, problems.join('\n')).toEqual([]);
2996
});
3097
});
98+
99+
// #2567 — prove the ratchet actually BITES. Drives `checkLedger` with controlled
100+
// inputs (deep-cloned matrix / synthetic discover) so it's deterministic and
101+
// needs no source edits. If these ever pass vacuously, the ratchet is asleep.
102+
describe('#2567 — anonymous-deny surface ratchet bites', () => {
103+
const clone = (): AuthzPrimitive[] => JSON.parse(JSON.stringify(AUTHZ_CONFORMANCE));
104+
const opts = (discover: () => Iterable<string>) => ({ proofRoot: HERE, highRisk: HIGH_RISK, discover });
105+
106+
it('the real matrix + real discover is sound (baseline lock)', () => {
107+
const problems = checkLedger(AUTHZ_CONFORMANCE, opts(() => discoverAnonymousDenySurfaces()));
108+
expect(problems).toEqual([]);
109+
});
110+
111+
it('(a) a row that DROPS its covers → UNCLASSIFIED surface failure', () => {
112+
const m = clone();
113+
const row = m.find((r) => r.id === 'anonymous-deny-hono-data')!;
114+
row.covers = [];
115+
const problems = checkLedger(m, opts(() => discoverAnonymousDenySurfaces()));
116+
expect(problems.some((p) => /UNCLASSIFIED surface/.test(p) && /data:hono-plugin\.ts/.test(p))).toBe(true);
117+
});
118+
119+
it('(b) a NEW ungated route appearing in source → UNCLASSIFIED surface failure', () => {
120+
const fake = 'data:hono-plugin.ts:DELETE /data/fake';
121+
const problems = checkLedger(
122+
AUTHZ_CONFORMANCE,
123+
opts(() => new Set([...discoverAnonymousDenySurfaces(), fake])),
124+
);
125+
expect(problems.some((p) => p.includes('UNCLASSIFIED surface') && p.includes(fake))).toBe(true);
126+
});
127+
128+
it('(c) a covers key no longer in source → STALE covers failure', () => {
129+
const m = clone();
130+
const row = m.find((r) => r.id === 'anonymous-deny-graphql')!;
131+
row.covers = [...(row.covers ?? []), 'graphql:http-dispatcher.ts:handleRemovedThing'];
132+
const problems = checkLedger(m, opts(() => discoverAnonymousDenySurfaces()));
133+
expect(problems.some((p) => /STALE covers/.test(p) && /handleRemovedThing/.test(p))).toBe(true);
134+
});
135+
});

0 commit comments

Comments
 (0)