Skip to content

Commit ade78fe

Browse files
committed
fix(metadata,spec): publish 门兜底 —— publishPackage 接门 + 匹配器装载期拒绝 (#5189, #5040 E7b)
E7 (#5111) hung the five per-endpoint `apis:` gates on `ObjectStackDefinitionSchema`, covering every path that parses a STACK. #5189 proved a stored `api` item need never have been part of one: `MetadataManager.publishPackage`, a direct `metadata.register()` and a Studio metadata write each mint one item at a time and saw no gate at all. Three of the five gates degrade safely when bypassed — the executor answers a structured 501 naming the item, and a path outside the `apps/<namespace>/` carve-out matches nothing. ADR-0121 D6 has NO runtime counterpart: the runtime honours `authRequired: false` faithfully and `deriveBucketConfig` returns null for a budget whose `enabled` is not `true`, so the bypass minted an anonymous, zero-quota execution entry point — the exact shape D6 forbids. Two doors, one judge: - publish — `publishPackage` runs `validateApiEndpointDeclarations` over the package's `api` items and fails the publish on its existing `validationErrors` surface, naming each endpoint and the key to fix. NOT governed by `options.validate`: an opt-out on a security gate is the bypass this closes. An item that does not satisfy `ApiEndpointSchema` fails here too — an unparsed shape cannot be gated, and the matcher refuses it at load anyway. - load — `buildEndpointIndex` re-applies the identity-free subset (supported subset, mapping, policy/D6) to every parsed item via the new `identityFreeEndpointGateFailure`, which delegates to the SAME `firstFailure` the full gate runs. A declaration that never passed publish is EXCLUDED and named at `error` level, so the bypass answers 404 with a loud log instead of answering anonymously and unmetered. The namespace and uniqueness gates are deliberately not applied there: both need a stack identity a stored row does not carry, and deriving a namespace from the very path being judged would be circular. Identity at publish: `MetadataManager` indexes items by `packageId` and carries no manifest, so it cannot prove a namespace. New additive option `publishPackage(id, { namespace })` lets a caller holding the manifest supply the explicit `manifest.namespace`; without it the namespace gate fails and the package's `api` items do not publish — the rule, not a limitation. It will NOT infer one from the items being judged: an author-supplied value would make the D1/D2 carve-out gate vacuous. Packages declaring no `api` items are untouched. `packages/spec/src/api/index.ts` now exports the gate module's four public names (it was package-internal in #5111, whose only consumer was one file away); `api-surface.json` moves by exactly those four on `./api`. Test fixtures in `endpoint-matcher.test.ts` / `metadata-manager-match-endpoint.test.ts` gained the `objectParams` E7's target gate requires — without it they describe an endpoint the runtime would 501 on, which the load gate now (correctly) excludes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd
1 parent e96ad55 commit ade78fe

10 files changed

Lines changed: 777 additions & 16 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata": minor
4+
---
5+
6+
fix(metadata,spec): the endpoint publish gates now guard the metadata write path too (#5189, #5040 E7b)
7+
8+
#5111 (E7) hung the five per-endpoint `apis:` gates on
9+
`ObjectStackDefinitionSchema`, which every path that parses a **stack** runs
10+
through — `defineStack`, `os validate`, the lint scorer, artifact ingest,
11+
`EnvironmentArtifactSchema.metadata`. #5189 proved a stored `api` item need
12+
never have been part of a stack: `MetadataManager.publishPackage`, a direct
13+
`metadata.register()` and a Studio metadata write each mint one item at a time
14+
and saw no gate at all.
15+
16+
Three of the five gates degrade safely when bypassed — the executor answers a
17+
structured 501 naming the item, and a path outside the `apps/<namespace>/`
18+
carve-out simply matches nothing. **ADR-0121 D6 has no runtime counterpart**:
19+
the runtime honours `authRequired: false` faithfully and `deriveBucketConfig`
20+
returns `null` for a budget whose `enabled` is not `true`, so the bypass minted
21+
an anonymous, zero-quota execution entry point — the exact shape D6 exists to
22+
forbid.
23+
24+
Two doors now, both running the SAME gate function rather than a second copy of
25+
the criteria:
26+
27+
- **Publish**`MetadataManager.publishPackage` runs
28+
`validateApiEndpointDeclarations` over the package's `api` items and fails
29+
the publish, naming each endpoint and the key to fix, on the same
30+
`validationErrors` surface it already uses. This pass is **not** governed by
31+
`options.validate`: an opt-out on a security gate is the bypass this fixed.
32+
- **Load** — the endpoint matcher's index build re-applies the *identity-free*
33+
subset (supported subset, mapping, policy/D6) to every stored item. A
34+
declaration that never passed publish is EXCLUDED from the index and named at
35+
`error` level, so a bypassed endpoint answers 404 with a loud log instead of
36+
answering anonymously and unmetered. The namespace and uniqueness gates are
37+
deliberately not applied there — both need a stack identity a stored row does
38+
not carry.
39+
40+
**New in `@objectstack/spec/api`** (the module was package-internal in #5111,
41+
whose only consumer was one file away):
42+
`validateApiEndpointDeclarations`, `identityFreeEndpointGateFailure`,
43+
`EndpointGateIssue`, `EndpointGateIdentity`.
44+
45+
**New option — `publishPackage(id, { namespace })`.** `MetadataManager` indexes
46+
items by `packageId` and carries no manifest, so it cannot prove a namespace on
47+
its own and will **not** infer one from the items it is judging (an
48+
author-supplied value would make the ADR-0121 D1/D2 carve-out gate vacuous).
49+
Callers that hold the package manifest pass its explicit `manifest.namespace`;
50+
without it the namespace gate fails and the package's `api` items do not
51+
publish — which is the rule, not a limitation: a publish that cannot prove a
52+
namespace must not mint a URL under one. Packages that declare no `api` items
53+
are untouched.

packages/metadata/src/endpoint-matcher.test.ts

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,25 @@ function makeLogger(): Logger & { error: ReturnType<typeof vi.fn> } {
4141
} as unknown as Logger & { error: ReturnType<typeof vi.fn> };
4242
}
4343

44-
/** A minimal, valid `ApiEndpointSchema` input. `authRequired` deliberately omitted. */
44+
/**
45+
* A minimal `ApiEndpointSchema` input that also PASSES the identity-free
46+
* publish gates (#5189). `authRequired` is deliberately omitted so the
47+
* schema-default tests still have something to prove.
48+
*
49+
* `objectParams` is not decoration: E7's target gate rejects an
50+
* `object_operation` that does not name both `object` and `operation`, and
51+
* since #5189 the index applies that gate too — a fixture without it would be
52+
* excluded rather than served, which is the correct behaviour and a useless
53+
* fixture.
54+
*/
4555
function endpoint(over: Record<string, unknown> = {}): Record<string, unknown> {
4656
return {
4757
name: 'list_tasks',
4858
path: '/api/v1/apps/showcase/tasks',
4959
method: 'GET',
5060
type: 'object_operation',
5161
target: 'showcase_task',
62+
objectParams: { object: 'showcase_task', operation: 'find' },
5263
...over,
5364
};
5465
}
@@ -109,7 +120,14 @@ describe('buildEndpointIndex', () => {
109120
});
110121

111122
it('preserves an explicit authRequired: false', () => {
112-
const index = buildEndpointIndex([endpoint({ authRequired: false })], makeLogger());
123+
// The armed budget is not incidental: since #5189 an anonymous endpoint
124+
// without one never reaches the index at all (ADR-0121 D6), so this is the
125+
// only shape in which "authRequired: false survives the round trip" is
126+
// still an observable fact.
127+
const index = buildEndpointIndex(
128+
[endpoint({ authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 } })],
129+
makeLogger(),
130+
);
113131
expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(false);
114132
});
115133

@@ -152,6 +170,100 @@ describe('parse failure — loud skip, no collateral damage', () => {
152170
});
153171
});
154172

173+
describe('#5189 — publish gates re-applied at load (identity-free subset)', () => {
174+
it('EXCLUDES an anonymous endpoint with no armed rate limit (ADR-0121 D6) and says so loudly', () => {
175+
const logger = makeLogger();
176+
const index = buildEndpointIndex([endpoint({ name: 'open_tasks', authRequired: false })], logger);
177+
178+
// The whole point: the route is gone, not served anonymously and unmetered.
179+
expect(index.size).toBe(0);
180+
expect(logger.error).toHaveBeenCalledTimes(1);
181+
const [message, , meta] = logger.error.mock.calls[0];
182+
expect(message).toContain('open_tasks');
183+
expect(message).toContain('WITHOUT passing the');
184+
expect(message).toContain('404');
185+
expect(message).toContain('Republish');
186+
// the gate's own prescription rides along
187+
expect(message).toContain('authRequired: false');
188+
expect(meta).toMatchObject({ name: 'open_tasks' });
189+
});
190+
191+
it('EXCLUDES a rateLimit that is present but not armed — `enabled` defaults to false', () => {
192+
const logger = makeLogger();
193+
const index = buildEndpointIndex(
194+
[endpoint({ name: 'open_tasks', authRequired: false, rateLimit: { windowMs: 60000, maxRequests: 100 } })],
195+
logger,
196+
);
197+
expect(index.size).toBe(0);
198+
expect(logger.error.mock.calls[0][0]).toContain('meters nothing');
199+
});
200+
201+
it('SERVES an anonymous endpoint that carries an armed budget', () => {
202+
const logger = makeLogger();
203+
const index = buildEndpointIndex(
204+
[
205+
endpoint({
206+
name: 'open_tasks',
207+
authRequired: false,
208+
rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 },
209+
}),
210+
],
211+
logger,
212+
);
213+
expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(false);
214+
expect(logger.error).not.toHaveBeenCalled();
215+
});
216+
217+
it('EXCLUDES the other identity-free gate failures too — one judge, not a D6 special case', () => {
218+
for (const bad of [
219+
endpoint({ name: 'proxied', type: 'proxy', target: 'https://x.test' }),
220+
endpoint({ name: 'no_params', objectParams: undefined }),
221+
endpoint({ name: 'mapped', outputMapping: [{ source: 'a', target: 'b', transform: 'upper' }] }),
222+
endpoint({ name: 'neg_cache', cacheTtl: -1 }),
223+
endpoint({ name: 'post_cache', method: 'POST', cacheTtl: 30 }),
224+
]) {
225+
const logger = makeLogger();
226+
expect(buildEndpointIndex([bad], logger).size).toBe(0);
227+
expect(logger.error).toHaveBeenCalledTimes(1);
228+
}
229+
});
230+
231+
it('does NOT apply the namespace gate — the matcher has no stack identity to judge it with', () => {
232+
// Outside any `apps/<ns>/` carve-out: publish rejects this (it knows the
233+
// manifest), the index does not (it does not, and inferring one from the
234+
// path being judged would be circular). It is simply unreachable in
235+
// practice — the endpoint step only consults paths under that mount.
236+
const logger = makeLogger();
237+
const index = buildEndpointIndex([endpoint({ name: 'stray', path: '/api/v1/elsewhere' })], logger);
238+
expect(index.has('GET /api/v1/elsewhere')).toBe(true);
239+
expect(logger.error).not.toHaveBeenCalled();
240+
});
241+
242+
it('excludes a gate-failing item without disturbing the good ones', () => {
243+
const logger = makeLogger();
244+
const index = buildEndpointIndex(
245+
[endpoint({ name: 'open_tasks', path: '/api/v1/apps/showcase/open', authRequired: false }), endpoint()],
246+
logger,
247+
);
248+
expect([...index.keys()]).toEqual(['GET /api/v1/apps/showcase/tasks']);
249+
expect(logger.error).toHaveBeenCalledTimes(1);
250+
});
251+
252+
it('a gate-failing item does not take the route from a valid duplicate claimant', () => {
253+
const logger = makeLogger();
254+
// `a_tasks` would win the lexicographic tie-break — but it never claims,
255+
// because it never passes the gates.
256+
const index = buildEndpointIndex(
257+
[endpoint({ name: 'a_tasks', authRequired: false }), endpoint({ name: 'z_tasks' })],
258+
logger,
259+
);
260+
expect(index.get('GET /api/v1/apps/showcase/tasks')!.name).toBe('z_tasks');
261+
// one gate error, and NO duplicate-claim error: there was never a duplicate
262+
expect(logger.error).toHaveBeenCalledTimes(1);
263+
expect(logger.error.mock.calls[0][0]).not.toContain('duplicate endpoint claim');
264+
});
265+
});
266+
155267
describe('duplicate METHOD+path claims — deterministic and loud', () => {
156268
it('keeps the lexicographically-first `name` and names the ignored claimant', () => {
157269
const logger = makeLogger();

packages/metadata/src/endpoint-matcher.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@
4949
* "absence must be loud" (AGENTS.md, Route & surface ownership §3). Skipping
5050
* one bad item never disturbs the good ones.
5151
*
52+
* ## The publish gates, applied a second time at load (#5189, #5040 E7b)
53+
*
54+
* Parsing is necessary and NOT sufficient. `ApiEndpointSchema` accepts shapes
55+
* the runtime refuses and shapes ADR-0121 forbids — `type: 'proxy'`, a mapping
56+
* `transform`, and above all `authRequired: false` with no armed `rateLimit`.
57+
* E7 (#5111) hung the gates that reject those on `ObjectStackDefinitionSchema`,
58+
* which covers every path that parses a STACK; #5189 proved that a stored `api`
59+
* item need never have been part of one (`metadata.register()`, a Studio write,
60+
* `publishPackage`). Most gates degrade safely when bypassed — the executor
61+
* answers a structured 501, a mis-namespaced path simply matches nothing — but
62+
* D6 has no runtime counterpart at all: the runtime honours `authRequired:
63+
* false` faithfully and `deriveBucketConfig` returns `null` for a disarmed
64+
* budget, so a bypassed D6 mints an anonymous, zero-quota execution entry
65+
* point. That is the exact shape D6 exists to prevent.
66+
*
67+
* So every parsed item is re-judged here by
68+
* {@link identityFreeEndpointGateFailure} — the SAME `firstFailure` the publish
69+
* gate runs, minus the two gates that need an identity this module does not
70+
* have. The asymmetry is deliberate and worth stating: the **namespace** gate
71+
* needs `manifest.namespace` (a stored row carries no manifest, and deriving
72+
* one from the very path being judged would be circular), and the
73+
* **uniqueness** gate is a per-stack rule that the duplicate-claim resolution
74+
* below already covers store-wide. An item failing an identity-free gate is
75+
* EXCLUDED from the index and named at `error` level, exactly like a parse
76+
* failure: a bypassed endpoint that answers 404 plus a loud log is the safe
77+
* failure; one that answers anonymously and unmetered is not. Publish is the
78+
* first door; this is the backstop, never the only door.
79+
*
5280
* ## Duplicate claims
5381
*
5482
* Two stored items may claim the same METHOD+path (publish rejects that inside
@@ -72,7 +100,12 @@
72100
* have recovered.
73101
*/
74102

75-
import { ApiEndpointSchema, normalizeEndpointPath, type ApiEndpoint } from '@objectstack/spec/api';
103+
import {
104+
ApiEndpointSchema,
105+
identityFreeEndpointGateFailure,
106+
normalizeEndpointPath,
107+
type ApiEndpoint,
108+
} from '@objectstack/spec/api';
76109
import type { ApiEndpointMatch } from '@objectstack/spec/contracts';
77110
import type { Logger } from '@objectstack/spec/contracts';
78111

@@ -145,6 +178,24 @@ export function buildEndpointIndex(items: readonly unknown[], logger: Logger): E
145178
}
146179

147180
const endpoint = parsed.data;
181+
182+
// [#5189, #5040 E7b] Second door: the identity-free publish gates. A stored
183+
// item that never passed publish is excluded rather than served — see the
184+
// module header for why D6 in particular cannot be left to the runtime.
185+
const gateFailure = identityFreeEndpointGateFailure(endpoint);
186+
if (gateFailure) {
187+
logger.error(
188+
`[EndpointMatcher] stored api item '${endpoint.name}' was stored WITHOUT passing the ` +
189+
`endpoint publish gates (#5040 E7 / ADR-0121) — it is EXCLUDED from endpoint matching and ` +
190+
`its declared route will answer 404. Republish it through a gated path (a stack artifact, ` +
191+
`or \`publishPackage\` with the package's \`manifest.namespace\`); a direct metadata write ` +
192+
`is not a publish. Gate failure: ${gateFailure.message}`,
193+
undefined,
194+
{ name: endpoint.name, issue: { path: gateFailure.path, message: gateFailure.message } },
195+
);
196+
continue;
197+
}
198+
148199
const key = endpointIndexKey(endpoint.method, endpoint.path);
149200
const incumbent = index.get(key);
150201

packages/metadata/src/metadata-manager-match-endpoint.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,20 @@ vi.mock('@objectstack/core', () => ({
3636
}),
3737
}));
3838

39+
/**
40+
* A stored `api` item that parses AND passes the identity-free publish gates
41+
* the index applies since #5189 — `objectParams` is required for an
42+
* `object_operation` (E7's target gate), so without it the item would be
43+
* excluded from the index instead of matched.
44+
*/
3945
function endpoint(over: Record<string, unknown> = {}): Record<string, unknown> {
4046
return {
4147
name: 'list_tasks',
4248
path: '/api/v1/apps/showcase/tasks',
4349
method: 'GET',
4450
type: 'object_operation',
4551
target: 'showcase_task',
52+
objectParams: { object: 'showcase_task', operation: 'find' },
4653
...over,
4754
};
4855
}
@@ -93,6 +100,34 @@ describe('#5089 — MetadataManager.matchEndpoint', () => {
93100
await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined();
94101
});
95102

103+
// ── #5189 (#5040 E7b) — the load-time backstop, end to end ─────────────
104+
//
105+
// `register()` is route 3 of #5189: a direct metadata write that no publish
106+
// gate ever sees. Before the backstop it minted an anonymous, zero-quota
107+
// execution entry point — the runtime honours `authRequired: false` and an
108+
// unarmed budget meters nothing. It must now MISS.
109+
describe('#5189 — a directly-registered item that never passed publish', () => {
110+
it('does not match when it violates ADR-0121 D6 (anonymous + no armed budget)', async () => {
111+
await manager.register('api', 'open_tasks', endpoint({ name: 'open_tasks', authRequired: false }));
112+
await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined();
113+
});
114+
115+
it('matches once the same declaration arms its budget', async () => {
116+
await manager.register(
117+
'api',
118+
'open_tasks',
119+
endpoint({
120+
name: 'open_tasks',
121+
authRequired: false,
122+
rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 },
123+
}),
124+
);
125+
const match = await manager.matchEndpoint(TASKS);
126+
expect(match?.endpoint.name).toBe('open_tasks');
127+
expect(match?.endpoint.authRequired).toBe(false);
128+
});
129+
});
130+
96131
describe('invalidation', () => {
97132
it('rebuilds after a register() — a newly declared endpoint is matchable', async () => {
98133
await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined();

0 commit comments

Comments
 (0)