Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/endpoint-publish-gate-backstop.md
Original file line number Diff line number Diff line change
@@ -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/<namespace>/`
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.
116 changes: 114 additions & 2 deletions packages/metadata/src/endpoint-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,25 @@ function makeLogger(): Logger & { error: ReturnType<typeof vi.fn> } {
} as unknown as Logger & { error: ReturnType<typeof vi.fn> };
}

/** 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<string, unknown> = {}): Record<string, unknown> {
return {
name: 'list_tasks',
path: '/api/v1/apps/showcase/tasks',
method: 'GET',
type: 'object_operation',
target: 'showcase_task',
objectParams: { object: 'showcase_task', operation: 'find' },
...over,
};
}
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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/<ns>/` 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();
Expand Down
53 changes: 52 additions & 1 deletion packages/metadata/src/endpoint-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';

Expand Down Expand Up @@ -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);

Expand Down
35 changes: 35 additions & 0 deletions packages/metadata/src/metadata-manager-match-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,20 @@ 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<string, unknown> = {}): Record<string, unknown> {
return {
name: 'list_tasks',
path: '/api/v1/apps/showcase/tasks',
method: 'GET',
type: 'object_operation',
target: 'showcase_task',
objectParams: { object: 'showcase_task', operation: 'find' },
...over,
};
}
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading