diff --git a/.changeset/endpoint-publish-gate-backstop.md b/.changeset/endpoint-publish-gate-backstop.md new file mode 100644 index 0000000000..ea4912d347 --- /dev/null +++ b/.changeset/endpoint-publish-gate-backstop.md @@ -0,0 +1,53 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata": minor +--- + +fix(metadata,spec): the endpoint publish gates now guard the metadata write path too (#5189, #5040 E7b) + +#5111 (E7) hung the five per-endpoint `apis:` gates on +`ObjectStackDefinitionSchema`, which every path that parses a **stack** runs +through — `defineStack`, `os validate`, the lint scorer, artifact ingest, +`EnvironmentArtifactSchema.metadata`. #5189 proved a stored `api` item need +never have been part of a stack: `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//` +carve-out simply 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 exists to +forbid. + +Two doors now, both running the SAME gate function rather than a second copy of +the criteria: + +- **Publish** — `MetadataManager.publishPackage` runs + `validateApiEndpointDeclarations` over the package's `api` items and fails + the publish, naming each endpoint and the key to fix, on the same + `validationErrors` surface it already uses. This pass is **not** governed by + `options.validate`: an opt-out on a security gate is the bypass this fixed. +- **Load** — the endpoint matcher's index build re-applies the *identity-free* + subset (supported subset, mapping, policy/D6) to every stored item. A + declaration that never passed publish is EXCLUDED from the index and named at + `error` level, so a bypassed endpoint 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. + +**New in `@objectstack/spec/api`** (the module was package-internal in #5111, +whose only consumer was one file away): +`validateApiEndpointDeclarations`, `identityFreeEndpointGateFailure`, +`EndpointGateIssue`, `EndpointGateIdentity`. + +**New option — `publishPackage(id, { namespace })`.** `MetadataManager` indexes +items by `packageId` and carries no manifest, so it cannot prove a namespace on +its own and will **not** infer one from the items it is judging (an +author-supplied value would make the ADR-0121 D1/D2 carve-out gate vacuous). +Callers that hold the package manifest pass its explicit `manifest.namespace`; +without it the namespace gate fails and the package's `api` items do not +publish — which is the rule, not a limitation: a publish that cannot prove a +namespace must not mint a URL under one. Packages that declare no `api` items +are untouched. diff --git a/packages/metadata/src/endpoint-matcher.test.ts b/packages/metadata/src/endpoint-matcher.test.ts index 67d15aa06f..2104fee71d 100644 --- a/packages/metadata/src/endpoint-matcher.test.ts +++ b/packages/metadata/src/endpoint-matcher.test.ts @@ -41,7 +41,17 @@ function makeLogger(): Logger & { error: ReturnType } { } as unknown as Logger & { error: ReturnType }; } -/** A minimal, valid `ApiEndpointSchema` input. `authRequired` deliberately omitted. */ +/** + * A minimal `ApiEndpointSchema` input that also PASSES the identity-free + * publish gates (#5189). `authRequired` is deliberately omitted so the + * schema-default tests still have something to prove. + * + * `objectParams` is not decoration: E7's target gate rejects an + * `object_operation` that does not name both `object` and `operation`, and + * since #5189 the index applies that gate too — a fixture without it would be + * excluded rather than served, which is the correct behaviour and a useless + * fixture. + */ function endpoint(over: Record = {}): Record { return { name: 'list_tasks', @@ -49,6 +59,7 @@ function endpoint(over: Record = {}): Record { method: 'GET', type: 'object_operation', target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, ...over, }; } @@ -109,7 +120,14 @@ describe('buildEndpointIndex', () => { }); it('preserves an explicit authRequired: false', () => { - const index = buildEndpointIndex([endpoint({ authRequired: false })], makeLogger()); + // The armed budget is not incidental: since #5189 an anonymous endpoint + // without one never reaches the index at all (ADR-0121 D6), so this is the + // only shape in which "authRequired: false survives the round trip" is + // still an observable fact. + const index = buildEndpointIndex( + [endpoint({ authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 } })], + makeLogger(), + ); expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(false); }); @@ -152,6 +170,100 @@ describe('parse failure — loud skip, no collateral damage', () => { }); }); +describe('#5189 — publish gates re-applied at load (identity-free subset)', () => { + it('EXCLUDES an anonymous endpoint with no armed rate limit (ADR-0121 D6) and says so loudly', () => { + const logger = makeLogger(); + const index = buildEndpointIndex([endpoint({ name: 'open_tasks', authRequired: false })], logger); + + // The whole point: the route is gone, not served anonymously and unmetered. + expect(index.size).toBe(0); + expect(logger.error).toHaveBeenCalledTimes(1); + const [message, , meta] = logger.error.mock.calls[0]; + expect(message).toContain('open_tasks'); + expect(message).toContain('WITHOUT passing the'); + expect(message).toContain('404'); + expect(message).toContain('Republish'); + // the gate's own prescription rides along + expect(message).toContain('authRequired: false'); + expect(meta).toMatchObject({ name: 'open_tasks' }); + }); + + it('EXCLUDES a rateLimit that is present but not armed — `enabled` defaults to false', () => { + const logger = makeLogger(); + const index = buildEndpointIndex( + [endpoint({ name: 'open_tasks', authRequired: false, rateLimit: { windowMs: 60000, maxRequests: 100 } })], + logger, + ); + expect(index.size).toBe(0); + expect(logger.error.mock.calls[0][0]).toContain('meters nothing'); + }); + + it('SERVES an anonymous endpoint that carries an armed budget', () => { + const logger = makeLogger(); + const index = buildEndpointIndex( + [ + endpoint({ + name: 'open_tasks', + authRequired: false, + rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, + }), + ], + logger, + ); + expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(false); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('EXCLUDES the other identity-free gate failures too — one judge, not a D6 special case', () => { + for (const bad of [ + endpoint({ name: 'proxied', type: 'proxy', target: 'https://x.test' }), + endpoint({ name: 'no_params', objectParams: undefined }), + endpoint({ name: 'mapped', outputMapping: [{ source: 'a', target: 'b', transform: 'upper' }] }), + endpoint({ name: 'neg_cache', cacheTtl: -1 }), + endpoint({ name: 'post_cache', method: 'POST', cacheTtl: 30 }), + ]) { + const logger = makeLogger(); + expect(buildEndpointIndex([bad], logger).size).toBe(0); + expect(logger.error).toHaveBeenCalledTimes(1); + } + }); + + it('does NOT apply the namespace gate — the matcher has no stack identity to judge it with', () => { + // Outside any `apps//` carve-out: publish rejects this (it knows the + // manifest), the index does not (it does not, and inferring one from the + // path being judged would be circular). It is simply unreachable in + // practice — the endpoint step only consults paths under that mount. + const logger = makeLogger(); + const index = buildEndpointIndex([endpoint({ name: 'stray', path: '/api/v1/elsewhere' })], logger); + expect(index.has('GET /api/v1/elsewhere')).toBe(true); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('excludes a gate-failing item without disturbing the good ones', () => { + const logger = makeLogger(); + const index = buildEndpointIndex( + [endpoint({ name: 'open_tasks', path: '/api/v1/apps/showcase/open', authRequired: false }), endpoint()], + logger, + ); + expect([...index.keys()]).toEqual(['GET /api/v1/apps/showcase/tasks']); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('a gate-failing item does not take the route from a valid duplicate claimant', () => { + const logger = makeLogger(); + // `a_tasks` would win the lexicographic tie-break — but it never claims, + // because it never passes the gates. + const index = buildEndpointIndex( + [endpoint({ name: 'a_tasks', authRequired: false }), endpoint({ name: 'z_tasks' })], + logger, + ); + expect(index.get('GET /api/v1/apps/showcase/tasks')!.name).toBe('z_tasks'); + // one gate error, and NO duplicate-claim error: there was never a duplicate + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0]).not.toContain('duplicate endpoint claim'); + }); +}); + describe('duplicate METHOD+path claims — deterministic and loud', () => { it('keeps the lexicographically-first `name` and names the ignored claimant', () => { const logger = makeLogger(); diff --git a/packages/metadata/src/endpoint-matcher.ts b/packages/metadata/src/endpoint-matcher.ts index a1431fb95d..ed9d748759 100644 --- a/packages/metadata/src/endpoint-matcher.ts +++ b/packages/metadata/src/endpoint-matcher.ts @@ -49,6 +49,34 @@ * "absence must be loud" (AGENTS.md, Route & surface ownership §3). Skipping * one bad item never disturbs the good ones. * + * ## The publish gates, applied a second time at load (#5189, #5040 E7b) + * + * Parsing is necessary and NOT sufficient. `ApiEndpointSchema` accepts shapes + * the runtime refuses and shapes ADR-0121 forbids — `type: 'proxy'`, a mapping + * `transform`, and above all `authRequired: false` with no armed `rateLimit`. + * E7 (#5111) hung the gates that reject those on `ObjectStackDefinitionSchema`, + * which covers every path that parses a STACK; #5189 proved that a stored `api` + * item need never have been part of one (`metadata.register()`, a Studio write, + * `publishPackage`). Most gates degrade safely when bypassed — the executor + * answers a structured 501, a mis-namespaced path simply matches nothing — but + * D6 has no runtime counterpart at all: the runtime honours `authRequired: + * false` faithfully and `deriveBucketConfig` returns `null` for a disarmed + * budget, so a bypassed D6 mints an anonymous, zero-quota execution entry + * point. That is the exact shape D6 exists to prevent. + * + * So every parsed item is re-judged here by + * {@link identityFreeEndpointGateFailure} — the SAME `firstFailure` the publish + * gate runs, minus the two gates that need an identity this module does not + * have. The asymmetry is deliberate and worth stating: the **namespace** gate + * needs `manifest.namespace` (a stored row carries no manifest, and deriving + * one from the very path being judged would be circular), and the + * **uniqueness** gate is a per-stack rule that the duplicate-claim resolution + * below already covers store-wide. An item failing an identity-free gate is + * EXCLUDED from the index and named at `error` level, exactly like a parse + * failure: a bypassed endpoint that answers 404 plus a loud log is the safe + * failure; one that answers anonymously and unmetered is not. Publish is the + * first door; this is the backstop, never the only door. + * * ## Duplicate claims * * Two stored items may claim the same METHOD+path (publish rejects that inside @@ -72,7 +100,12 @@ * have recovered. */ -import { ApiEndpointSchema, normalizeEndpointPath, type ApiEndpoint } from '@objectstack/spec/api'; +import { + ApiEndpointSchema, + identityFreeEndpointGateFailure, + normalizeEndpointPath, + type ApiEndpoint, +} from '@objectstack/spec/api'; import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; import type { Logger } from '@objectstack/spec/contracts'; @@ -145,6 +178,24 @@ export function buildEndpointIndex(items: readonly unknown[], logger: Logger): E } const endpoint = parsed.data; + + // [#5189, #5040 E7b] Second door: the identity-free publish gates. A stored + // item that never passed publish is excluded rather than served — see the + // module header for why D6 in particular cannot be left to the runtime. + const gateFailure = identityFreeEndpointGateFailure(endpoint); + if (gateFailure) { + logger.error( + `[EndpointMatcher] stored api item '${endpoint.name}' was stored WITHOUT passing the ` + + `endpoint publish gates (#5040 E7 / ADR-0121) — it is EXCLUDED from endpoint matching and ` + + `its declared route will answer 404. Republish it through a gated path (a stack artifact, ` + + `or \`publishPackage\` with the package's \`manifest.namespace\`); a direct metadata write ` + + `is not a publish. Gate failure: ${gateFailure.message}`, + undefined, + { name: endpoint.name, issue: { path: gateFailure.path, message: gateFailure.message } }, + ); + continue; + } + const key = endpointIndexKey(endpoint.method, endpoint.path); const incumbent = index.get(key); diff --git a/packages/metadata/src/metadata-manager-match-endpoint.test.ts b/packages/metadata/src/metadata-manager-match-endpoint.test.ts index 0e9885a825..3538c5c26e 100644 --- a/packages/metadata/src/metadata-manager-match-endpoint.test.ts +++ b/packages/metadata/src/metadata-manager-match-endpoint.test.ts @@ -36,6 +36,12 @@ vi.mock('@objectstack/core', () => ({ }), })); +/** + * A stored `api` item that parses AND passes the identity-free publish gates + * the index applies since #5189 — `objectParams` is required for an + * `object_operation` (E7's target gate), so without it the item would be + * excluded from the index instead of matched. + */ function endpoint(over: Record = {}): Record { return { name: 'list_tasks', @@ -43,6 +49,7 @@ function endpoint(over: Record = {}): Record { method: 'GET', type: 'object_operation', target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, ...over, }; } @@ -93,6 +100,34 @@ describe('#5089 — MetadataManager.matchEndpoint', () => { await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined(); }); + // ── #5189 (#5040 E7b) — the load-time backstop, end to end ───────────── + // + // `register()` is route 3 of #5189: a direct metadata write that no publish + // gate ever sees. Before the backstop it minted an anonymous, zero-quota + // execution entry point — the runtime honours `authRequired: false` and an + // unarmed budget meters nothing. It must now MISS. + describe('#5189 — a directly-registered item that never passed publish', () => { + it('does not match when it violates ADR-0121 D6 (anonymous + no armed budget)', async () => { + await manager.register('api', 'open_tasks', endpoint({ name: 'open_tasks', authRequired: false })); + await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined(); + }); + + it('matches once the same declaration arms its budget', async () => { + await manager.register( + 'api', + 'open_tasks', + endpoint({ + name: 'open_tasks', + authRequired: false, + rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, + }), + ); + const match = await manager.matchEndpoint(TASKS); + expect(match?.endpoint.name).toBe('open_tasks'); + expect(match?.endpoint.authRequired).toBe(false); + }); + }); + describe('invalidation', () => { it('rebuilds after a register() — a newly declared endpoint is matchable', async () => { await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined(); diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index c794e8a601..f41e4bca8c 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -49,6 +49,13 @@ import { MetadataEventSchema, type MetadataEvent as RealtimeMetadataEvent, } from '@objectstack/spec/api'; +// [#5189, #5040 E7b] The endpoint publish gates, reused verbatim — see +// `gateApiItemsForPublish`. +import { + ApiEndpointSchema, + validateApiEndpointDeclarations, + type ApiEndpoint, +} from '@objectstack/spec/api'; import { createLogger, type Logger } from '@objectstack/core'; import { JSONSerializer } from './serializers/json-serializer.js'; import { YAMLSerializer } from './serializers/yaml-serializer.js'; @@ -71,6 +78,19 @@ import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; */ export type WatchCallback = (event: MetadataWatchEvent) => void | Promise; +/** + * [#5189] Appended to the namespace gate's message when `publishPackage` was + * called without one, because the gate's own text ("declare an explicit + * `manifest.namespace`") describes a stack file this caller may not have. + */ +const PUBLISH_NAMESPACE_REMEDY = + 'From `MetadataManager.publishPackage` specifically: this method indexes items by `packageId` and ' + + 'carries no manifest, so it cannot prove a namespace on its own and will not infer one from the ' + + 'items being published (an author-supplied value would make the carve-out gate vacuous). Pass the ' + + "package's explicit namespace as `publishPackage(id, { namespace })`, or publish the endpoints as " + + 'part of a stack artifact (`defineStack` → compile → artifact ingest), which carries the manifest ' + + 'and runs these same gates at parse time.'; + /** * RFC-4122 v4 uuid for realtime `MetadataEvent.id` (#4602). * Prefers `crypto.randomUUID`; the fallback keeps browser-compatible (Pure) @@ -805,11 +825,32 @@ export class MetadataManager implements IMetadataService { * 2. Snapshot all items in the package (publishedDefinition = clone(metadata)) * 3. Increment version * 4. Set all items state → active + * + * [#5189, #5040 E7b] Step 1 additionally runs the **endpoint publish gates** + * over every `api` item — see {@link gateApiItemsForPublish}. That pass is + * NOT governed by `options.validate`: the gates are a contract, not a + * lint (ADR-0121 D6 says publish REJECTS an unmetered anonymous endpoint), + * and an opt-out flag on a security gate is the bypass this issue closed. */ async publishPackage(packageId: string, options?: { changeNote?: string; publishedBy?: string; validate?: boolean; + /** + * [#5189] The package manifest's EXPLICIT `manifest.namespace` (ADR-0121 + * D2), supplied by a caller that holds the manifest. + * + * `MetadataManager` has no manifest concept — it indexes items by + * `packageId` and nothing else — so it cannot prove a namespace on its + * own, and an `api` item's own `namespace`-ish fields are author-supplied + * data, not identity (reading them would make the D1/D2 carve-out gate + * vacuous: an author would simply declare the namespace their path + * already uses). Absent this option the namespace gate fails and `api` + * items in the package cannot publish through this path — which is the + * correct outcome, not a limitation to route around: a publish that + * cannot prove a namespace must not mint a URL under one. + */ + namespace?: string; }): Promise { const now = new Date().toISOString(); const shouldValidate = options?.validate !== false; @@ -837,10 +878,19 @@ export class MetadataManager implements IMetadataService { }; } + const validationErrors: Array<{ type: string; name: string; message: string }> = []; + + // [#5189, #5040 E7b] Endpoint publish gates — ALWAYS, `validate: false` + // included. Every other check in this method is a best-effort quality + // check whose opt-out is a convenience; these gates decide whether an + // externally reachable, possibly ANONYMOUS execution entry point comes + // into existence, and ADR-0121 D6 has no runtime counterpart to catch what + // slips through. A flag that turns them off would be exactly the bypass + // #5189 filed. + validationErrors.push(...this.gateApiItemsForPublish(packageItems, options?.namespace)); + // Validation pass if (shouldValidate) { - const validationErrors: Array<{ type: string; name: string; message: string }> = []; - // Schema validation for (const item of packageItems) { const result = await this.validate(item.type, item.data); @@ -883,17 +933,17 @@ export class MetadataManager implements IMetadataService { } } } + } - if (validationErrors.length > 0) { - return { - success: false, - packageId, - version: 0, - publishedAt: now, - itemsPublished: 0, - validationErrors, - }; - } + if (validationErrors.length > 0) { + return { + success: false, + packageId, + version: 0, + publishedAt: now, + itemsPublished: 0, + validationErrors, + }; } // Determine the next version by finding the max current version across items @@ -926,6 +976,98 @@ export class MetadataManager implements IMetadataService { }; } + /** + * [#5189, #5040 E7b] Run the endpoint publish gates over a package's `api` + * items and report every failure as a publish-blocking validation error. + * + * ## Why this exists at all + * + * E7 (#5111) hung the five per-endpoint gates on + * `ObjectStackDefinitionSchema`, which covers every path that parses a + * STACK — `defineStack`, `os validate`, the lint scorer, artifact ingest, + * `EnvironmentArtifactSchema.metadata`. It does not cover this one: an `api` + * item can be minted item-by-item (`metadata.register()`, a Studio write) + * and published here without a stack ever being parsed. Three of the gates + * degrade safely when bypassed (the executor answers a structured 501; a + * mis-namespaced path matches nothing), but **ADR-0121 D6 has no runtime + * counterpart**: `authRequired: false` is honoured faithfully and an + * unarmed `rateLimit` meters nothing, so the bypass mints an anonymous, + * zero-quota execution entry point. Hence a gate here, on the same + * function, rather than a second set of criteria that would drift. + * + * ## What it judges, and on what + * + * The registry stores either a raw spec document or a publish envelope + * (`{ name, packageId, state, metadata: {…spec} }`); the endpoint is read + * out with the SAME rule this method's caller uses for + * `publishedDefinition` (`data.metadata ?? data`), so publish gates exactly + * the document publish is about to snapshot. An item that does not satisfy + * `ApiEndpointSchema` fails here too — not extra strictness but a + * precondition: an unparsed shape cannot be gated, and it could never be + * served either (the matcher's own loud skip refuses it at load). + * + * @param packageItems every item collected for this package (all types). + * @param namespace the caller-supplied `manifest.namespace`; `undefined` + * fails the namespace gate, deliberately — see `publishPackage`'s option. + * @returns one entry per gate failure, `[]` when the package declares no + * `api` items (a package without endpoints is untouched by this pass). + */ + private gateApiItemsForPublish( + packageItems: Array<{ type: string; name: string; data: any }>, + namespace: string | undefined, + ): Array<{ type: string; name: string; message: string }> { + const apiItems = packageItems.filter(i => i.type === MetadataManager.ENDPOINT_METADATA_TYPE); + if (apiItems.length === 0) return []; + + const errors: Array<{ type: string; name: string; message: string }> = []; + /** Parsed endpoints, index-aligned with the items that produced them. */ + const endpoints: ApiEndpoint[] = []; + const gatedItems: Array<{ name: string }> = []; + + for (const item of apiItems) { + const document = item.data?.metadata ?? item.data; + const parsed = ApiEndpointSchema.safeParse(document); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + errors.push({ + type: item.type, + name: item.name, + message: + `api item '${item.name}' does not satisfy ApiEndpointSchema and cannot be published: ` + + `${issue.message} (at ${issue.path.join('.') || ''}). An endpoint that does not ` + + `parse cannot be gated and would be excluded from endpoint matching at load anyway.`, + }); + } + continue; + } + endpoints.push(parsed.data); + gatedItems.push({ name: item.name }); + } + + for (const issue of validateApiEndpointDeclarations(endpoints, { namespace })) { + // The gate reports per-endpoint issues at `['apis', , …]` and the + // namespace PRECONDITION once at `['apis']` — the latter is a property of + // the publish call, not of any one endpoint, so it is reported once with + // this path's own remedy appended. + const index = typeof issue.path[1] === 'number' ? issue.path[1] : undefined; + if (index === undefined) { + errors.push({ + type: MetadataManager.ENDPOINT_METADATA_TYPE, + name: '', + message: `${issue.message} ${PUBLISH_NAMESPACE_REMEDY}`, + }); + continue; + } + errors.push({ + type: MetadataManager.ENDPOINT_METADATA_TYPE, + name: gatedItems[index]?.name ?? '', + message: issue.message, + }); + } + + return errors; + } + /** * Revert entire package to last published state. * Restores all metadata definitions from their published snapshots. diff --git a/packages/metadata/src/publish-endpoint-gate.test.ts b/packages/metadata/src/publish-endpoint-gate.test.ts new file mode 100644 index 0000000000..36b3766913 --- /dev/null +++ b/packages/metadata/src/publish-endpoint-gate.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5189 (#5040 E7b) — `publishPackage` runs the endpoint publish gates. + * + * E7 (#5111) hung the five per-endpoint gates on `ObjectStackDefinitionSchema`, + * covering every path that parses a STACK. This file pins the path that never + * does: an `api` item minted item-by-item (`metadata.register()`, a Studio + * write) and promoted by `publishPackage`, which before #5189 reached `state: + * 'active'` without any gate seeing it. The one that mattered is ADR-0121 D6 — + * `authRequired: false` with no armed `rateLimit` — because it is the only gate + * with NO runtime counterpart: the executor honours anonymous access faithfully + * and an unarmed budget meters nothing, so the bypass minted an anonymous, + * zero-quota execution entry point. + * + * The load-time half of the backstop lives in `endpoint-matcher.test.ts`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MetadataManager } from './metadata-manager.js'; +import { MemoryLoader } from './loaders/memory-loader.js'; + +vi.mock('@objectstack/core', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +const PKG = 'com.acme.endpoints'; +const NS = 'acme'; + +/** A stored `api` item that passes every gate under `namespace: 'acme'`. */ +function apiItem(over: Record = {}): Record { + return { + name: 'list_things', + path: `/api/v1/apps/${NS}/things`, + method: 'GET', + type: 'object_operation', + target: 'acme_thing', + objectParams: { object: 'acme_thing', operation: 'find' }, + packageId: PKG, + state: 'draft', + ...over, + }; +} + +describe('#5189 — publishPackage gates `api` items', () => { + let manager: MetadataManager; + + beforeEach(() => { + manager = new MetadataManager({ formats: ['json'], loaders: [new MemoryLoader()] }); + }); + + // ── The security case: ADR-0121 D6 ───────────────────────────────────── + describe('ADR-0121 D6 — anonymous requires an ARMED budget', () => { + it('REJECTS an `authRequired: false` endpoint with no rateLimit, naming the endpoint and the key', async () => { + await manager.register('api', 'open_things', apiItem({ name: 'open_things', authRequired: false })); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + + expect(result.success).toBe(false); + expect(result.itemsPublished).toBe(0); + const errors = result.validationErrors ?? []; + expect(errors.length).toBeGreaterThan(0); + const d6 = errors.find(e => e.message.includes('authRequired: false')); + expect(d6).toBeDefined(); + // names the endpoint … + expect(d6!.name).toBe('open_things'); + expect(d6!.type).toBe('api'); + expect(d6!.message).toContain('open_things'); + // … and the key to fix, with the armed spelling. + expect(d6!.message).toContain('rateLimit'); + expect(d6!.message).toContain('enabled: true'); + }); + + it('REJECTS a rateLimit that is present but NOT armed (`enabled` defaults to false)', async () => { + await manager.register( + 'api', + 'open_things', + apiItem({ + name: 'open_things', + authRequired: false, + rateLimit: { windowMs: 60000, maxRequests: 100 }, + }), + ); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + expect(result.success).toBe(false); + expect(result.validationErrors!.some(e => e.message.includes('meters nothing'))).toBe(true); + }); + + it('PUBLISHES an anonymous endpoint that carries an armed budget', async () => { + await manager.register( + 'api', + 'open_things', + apiItem({ + name: 'open_things', + authRequired: false, + rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, + }), + ); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + expect(result.success).toBe(true); + expect(result.itemsPublished).toBe(1); + const stored = (await manager.get('api', 'open_things')) as Record; + expect(stored.state).toBe('active'); + }); + + it('is NOT opt-outable with `validate: false` — a gate is not a lint', async () => { + await manager.register('api', 'open_things', apiItem({ name: 'open_things', authRequired: false })); + + const result = await manager.publishPackage(PKG, { namespace: NS, validate: false }); + expect(result.success).toBe(false); + expect(result.validationErrors!.some(e => e.message.includes('authRequired: false'))).toBe(true); + }); + }); + + // ── The rest of the gates come along, because it is ONE function ──────── + describe('the other gates ride the same call', () => { + it('rejects an unsupported target type (`proxy`)', async () => { + await manager.register('api', 'proxied', apiItem({ name: 'proxied', type: 'proxy', target: 'https://x.test' })); + const result = await manager.publishPackage(PKG, { namespace: NS }); + expect(result.success).toBe(false); + expect(result.validationErrors!.some(e => e.message.includes("type: 'proxy'"))).toBe(true); + }); + + it('rejects a path outside the stack carve-out (ADR-0121 D1)', async () => { + await manager.register('api', 'list_things', apiItem({ path: '/api/v1/things' })); + const result = await manager.publishPackage(PKG, { namespace: NS }); + expect(result.success).toBe(false); + expect(result.validationErrors!.some(e => e.message.includes('carve-out'))).toBe(true); + }); + + it('rejects two items claiming the same METHOD + normalized path', async () => { + await manager.register('api', 'a_things', apiItem({ name: 'a_things' })); + await manager.register('api', 'b_things', apiItem({ name: 'b_things', path: `/api/v1/apps/${NS}/things/` })); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + expect(result.success).toBe(false); + expect(result.validationErrors!.some(e => e.message.includes('already claimed by endpoint'))).toBe(true); + }); + + it('reports EVERY bad endpoint, not just the first', async () => { + await manager.register('api', 'bad_one', apiItem({ name: 'bad_one', type: 'proxy', target: 'https://x.test' })); + await manager.register('api', 'bad_two', apiItem({ name: 'bad_two', path: '/api/v1/elsewhere', method: 'POST' })); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + expect(result.success).toBe(false); + const named = (result.validationErrors ?? []).map(e => e.name); + expect(named).toContain('bad_one'); + expect(named).toContain('bad_two'); + }); + }); + + // ── Identity: this path cannot prove a namespace on its own ───────────── + describe('namespace identity (ADR-0121 D2 / #5040 Q1 = A)', () => { + it('refuses to publish `api` items when no namespace was supplied, and says why HERE', async () => { + await manager.register('api', 'list_things', apiItem()); + + const result = await manager.publishPackage(PKG); + + expect(result.success).toBe(false); + const precondition = result.validationErrors!.find(e => e.message.includes('manifest.namespace')); + expect(precondition).toBeDefined(); + // Reported ONCE, against the type rather than any one endpoint … + expect(precondition!.name).toBe(''); + expect(precondition!.type).toBe('api'); + // … and carries this call's own remedy, not only the stack-file one. + expect(precondition!.message).toContain('publishPackage(id, { namespace })'); + }); + + it('does NOT infer the namespace from the item being judged', async () => { + // The item declares a `namespace` field and a matching path; believing it + // would make the carve-out gate vacuous. + await manager.register('api', 'list_things', apiItem({ namespace: NS })); + + const result = await manager.publishPackage(PKG); + expect(result.success).toBe(false); + expect(result.validationErrors!.some(e => e.message.includes('manifest.namespace'))).toBe(true); + }); + }); + + // ── Precondition + blast radius ──────────────────────────────────────── + describe('shape and scope', () => { + it('rejects an `api` item that does not satisfy ApiEndpointSchema', async () => { + await manager.register('api', 'broken', { name: 'broken', path: '/api/v1/apps/acme/x', packageId: PKG }); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + expect(result.success).toBe(false); + expect(result.validationErrors!.some(e => e.message.includes('ApiEndpointSchema'))).toBe(true); + }); + + it('reads a publish ENVELOPE\'s `metadata` — the same document publish snapshots', async () => { + await manager.register('api', 'open_things', { + name: 'open_things', + packageId: PKG, + state: 'draft', + metadata: apiItem({ name: 'open_things', authRequired: false }), + }); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + expect(result.success).toBe(false); + expect(result.validationErrors!.some(e => e.message.includes('authRequired: false'))).toBe(true); + }); + + it('leaves a package with no `api` items completely untouched', async () => { + await manager.register('object', 'acme_thing', { + name: 'acme_thing', label: 'Thing', packageId: PKG, state: 'draft', + metadata: { fields: ['name'] }, + }); + await manager.register('view', 'thing_list', { + name: 'thing_list', label: 'Things', packageId: PKG, state: 'draft', + metadata: { columns: ['name'] }, + }); + + // No namespace supplied, and none needed: the gate pass returns early. + const result = await manager.publishPackage(PKG); + expect(result.success).toBe(true); + expect(result.itemsPublished).toBe(2); + }); + + it('fails the WHOLE publish, leaving the good items unpublished (publish is atomic)', async () => { + await manager.register('api', 'good_things', apiItem({ name: 'good_things' })); + await manager.register('api', 'open_things', apiItem({ + name: 'open_things', + path: `/api/v1/apps/${NS}/open`, + authRequired: false, + })); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + expect(result.success).toBe(false); + const good = (await manager.get('api', 'good_things')) as Record; + expect(good.state).toBe('draft'); + expect(good.publishedDefinition).toBeUndefined(); + }); + }); +}); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 46a7a6781c..d503abae2d 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2541,6 +2541,8 @@ "EnablePackageRequestSchema (const)", "EnablePackageResponse (type)", "EnablePackageResponseSchema (const)", + "EndpointGateIdentity (interface)", + "EndpointGateIssue (interface)", "EndpointMapping (const)", "EndpointMappingKey (type)", "EndpointRegistry (type)", @@ -3124,9 +3126,11 @@ "envelopeViolations (function)", "getAuthEndpointUrl (function)", "getDefaultRouteRegistrations (function)", + "identityFreeEndpointGateFailure (function)", "normalizeEndpointPath (function)", "readServiceSelfInfo (function)", - "standardErrorCodeForHttpStatus (function)" + "standardErrorCodeForHttpStatus (function)", + "validateApiEndpointDeclarations (function)" ], "./ui": [ "ACTION_LOCATIONS (const)", diff --git a/packages/spec/src/api/apis-publish-gates.test.ts b/packages/spec/src/api/apis-publish-gates.test.ts index 71e64c7351..808bfa96f4 100644 --- a/packages/spec/src/api/apis-publish-gates.test.ts +++ b/packages/spec/src/api/apis-publish-gates.test.ts @@ -35,6 +35,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; import { ApiEndpointSchema, normalizeEndpointPath } from './endpoint.zod'; +import { identityFreeEndpointGateFailure, validateApiEndpointDeclarations } from './endpoint-publish-gate'; const manifest = { id: 'com.example.apis', @@ -497,3 +498,70 @@ describe('[#5111] the `ApiEndpoint` vocabulary itself is untouched', () => { expect(() => ApiEndpointSchema.parse({ name: 'Bad Name', path: 'no-slash' })).toThrow(); }); }); + +// ============================================================================ +// [#5189 / #5040 E7b] The identity-free subset, for consumers holding one +// stored endpoint and no stack. +// ============================================================================ + +describe('identityFreeEndpointGateFailure — the same judge, minus stack identity', () => { + it('passes an endpoint that passes the full gates', () => { + expect(identityFreeEndpointGateFailure(ApiEndpointSchema.parse(validObjectEndpoint))).toBeUndefined(); + }); + + it('still refuses D6 — the gate with no runtime counterpart, and the reason #5189 exists', () => { + const failure = identityFreeEndpointGateFailure( + ApiEndpointSchema.parse({ ...validObjectEndpoint, cacheTtl: undefined, authRequired: false }), + ); + expect(failure).toBeDefined(); + expect(failure!.path).toEqual(['rateLimit']); + expect(failure!.message).toContain('authRequired: false'); + expect(failure!.message).toContain('enabled: true'); + }); + + it('accepts an anonymous endpoint whose budget is ARMED', () => { + expect( + identityFreeEndpointGateFailure( + ApiEndpointSchema.parse({ + ...validObjectEndpoint, + cacheTtl: undefined, + authRequired: false, + rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, + }), + ), + ).toBeUndefined(); + }); + + it('still refuses the target, mapping and policy gates', () => { + const cases: Array<[Record, (string | number)[]]> = [ + [{ type: 'proxy', target: 'https://x.test', objectParams: undefined }, ['type']], + [{ objectParams: { object: 'showcase_task' } }, ['objectParams']], + [{ outputMapping: [{ source: 'a', target: 'b', transform: 'upper' }] }, ['outputMapping', 0, 'transform']], + [{ cacheTtl: -1 }, ['cacheTtl']], + ]; + for (const [over, path] of cases) { + const failure = identityFreeEndpointGateFailure( + ApiEndpointSchema.parse({ ...validObjectEndpoint, ...over }), + ); + expect(failure).toBeDefined(); + // Paths are relative to the ENDPOINT, not to a stack's `apis:` array. + expect(failure!.path).toEqual(path); + } + }); + + it('does NOT judge the namespace — that gate needs a manifest this caller does not have', () => { + // The full gate rejects this path under `namespace: 'showcase'`; the + // identity-free subset cannot, and must not pretend to. + const stray = ApiEndpointSchema.parse({ ...validObjectEndpoint, path: '/api/v1/elsewhere' }); + expect(identityFreeEndpointGateFailure(stray)).toBeUndefined(); + expect(validateApiEndpointDeclarations([stray], { namespace: 'showcase' })).toHaveLength(1); + }); + + it('does NOT judge uniqueness — a single endpoint has no siblings to collide with', () => { + const one = ApiEndpointSchema.parse(validObjectEndpoint); + expect(identityFreeEndpointGateFailure(one)).toBeUndefined(); + expect(identityFreeEndpointGateFailure(one)).toBeUndefined(); + // …while the full gate still catches the pair. + expect(validateApiEndpointDeclarations([one, one], { namespace: 'showcase' })).toHaveLength(1); + }); +}); diff --git a/packages/spec/src/api/endpoint-publish-gate.ts b/packages/spec/src/api/endpoint-publish-gate.ts index 12e9ac9eb9..f94dab6e13 100644 --- a/packages/spec/src/api/endpoint-publish-gate.ts +++ b/packages/spec/src/api/endpoint-publish-gate.ts @@ -208,6 +208,46 @@ export function validateApiEndpointDeclarations( return issues; } +/** + * The IDENTITY-FREE gates for ONE already-parsed endpoint (#5189, #5040 E7b). + * + * Everything {@link validateApiEndpointDeclarations} judges EXCEPT the two + * gates that need a stack identity the caller may not have: + * + * - the **namespace** gate (ADR-0121 D1/D2) needs `manifest.namespace`, and + * - the **uniqueness** gate needs the sibling declarations of the same stack. + * + * Everything else — supported subset, mapping, policy (D6 included) — is + * judgeable from the endpoint alone, so a consumer holding a single stored + * declaration and no manifest can still refuse the shapes the runtime cannot + * serve. The load-time backstop in the endpoint matcher + * (`packages/metadata/src/endpoint-matcher.ts`) is exactly that consumer: a + * declaration reaches the store through paths that never saw a manifest + * (a direct `metadata.register()`, a Studio write), and D6 is the one gate + * with no runtime counterpart — an unmetered anonymous endpoint executes + * faithfully, which is precisely what D6 exists to prevent (#5189). + * + * Additive on purpose: it delegates to the SAME {@link firstFailure} the + * full gate runs, so publish-time and load-time can never grow two opinions + * of what is servable. The issue `path` it returns is relative to the + * endpoint itself (`['rateLimit']`), not to a stack's `apis:` array. + * + * @returns the first gate failure, or `undefined` when the endpoint passes + * every identity-free gate. + */ +export function identityFreeEndpointGateFailure(endpoint: ApiEndpoint): EndpointGateIssue | undefined { + return firstFailure( + endpoint, + `Endpoint '${endpoint.name}'`, + (...rest) => rest, + // No mount → `namespaceGate` returns undefined (it is "already reported + // once, against `apis`" in the full run); no claims → `uniquenessGate` + // has nothing to collide with. Both asymmetries are the point. + undefined, + new Map(), + ); +} + /** The normalized claim key — the matcher's own index key (`endpointIndexKey`). */ function claimKey(endpoint: ApiEndpoint): string { return `${endpoint.method.toUpperCase()} ${normalizeEndpointPath(endpoint.path)}`; diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index 18af2e4aeb..9751996cd2 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -15,6 +15,21 @@ export * from './contract.zod'; export * from './endpoint.zod'; +// [#5189, #5040 E7b] The per-endpoint publish gates. #5111 kept this module +// package-internal because its only consumer was `ObjectStackDefinitionSchema`, +// one file away. #5189 proved the stack schema is NOT the only door: a +// `MetadataManager.publishPackage` / `metadata.register()` / Studio write mints +// an `api` item without ever parsing a stack, and ADR-0121 D6 (anonymous +// endpoints must carry an armed budget) has no runtime counterpart to catch it. +// The gate therefore becomes public so the metadata layer can reuse the SAME +// criteria at publish and at index-build time — one judge, three doors, rather +// than a second implementation that drifts. +export { + validateApiEndpointDeclarations, + identityFreeEndpointGateFailure, + type EndpointGateIssue, + type EndpointGateIdentity, +} from './endpoint-publish-gate'; export * from './discovery.zod'; export * from './events.zod'; export * from './realtime-shared.zod';