diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 83539e9884..e1d724e659 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -2161,9 +2161,14 @@ export class MetadataManager implements IMetadataService { * already resolve the `metadata` service for the environment they serve — * adding one here would create a second scoping mechanism. * - * Nothing reaches this method over HTTP in 17.x: the dispatcher seam is - * #5090's, and publish still rejects a non-empty `apis:` (#4936), so the - * whole path is structurally unreachable until the #5040 E7 flip. + * This method is reached over HTTP on a real boot. The dispatcher seam + * landed as #5090 (`packages/runtime/src/api-endpoint-step.ts`, called from + * the `setFallbackHandler` the dispatcher plugin installs), and #4936's + * wholesale publish refusal of a non-empty `apis:` was replaced by the + * #5040 E7 per-shape gates (`packages/spec/src/api/endpoint-publish-gate.ts`) + * — so declarations exist and requests arrive here. The showcase's two + * declared endpoints are matched and executed through this path in + * `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`. * * @throws when the metadata store cannot be read — an outage must never be * reported as a miss, because a miss becomes a 404. diff --git a/packages/rest/src/openapi-endpoints.test.ts b/packages/rest/src/openapi-endpoints.test.ts index 908ac50ae4..4daada8265 100644 --- a/packages/rest/src/openapi-endpoints.test.ts +++ b/packages/rest/src/openapi-endpoints.test.ts @@ -3,14 +3,17 @@ /** * #5040 E6 — declared endpoints in the OpenAPI document. * - * Two jobs, and the second one is the load-bearing one TODAY: + * Two jobs: * * 1. the positive shapes, driven straight through the pure enrichment with - * parsed declarations (publish still refuses to let any of them exist, so - * there is no boot that could exercise them end to end yet); + * parsed declarations. Since the #5040 E7 publish flip these are the LIVE + * path — a real showcase boot serves an `/openapi.json` carrying its two + * declared endpoints + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`); + * the cases here pin the projection itself, apart from any boot; * 2. the empty-set invariant — with no declarations the document must come - * back not merely equivalent but IDENTICAL, because that is the entire - * live-behaviour claim this change makes until the E7 flip. + * back not merely equivalent but IDENTICAL, which is what keeps a + * deployment that declares nothing byte-for-byte unchanged. */ import { describe, it, expect, vi } from 'vitest'; diff --git a/packages/rest/src/openapi-endpoints.ts b/packages/rest/src/openapi-endpoints.ts index a016f10a4a..1f1eb930e9 100644 --- a/packages/rest/src/openapi-endpoints.ts +++ b/packages/rest/src/openapi-endpoints.ts @@ -55,12 +55,20 @@ * the mirror; it is not built here because Prime Directive #2 keeps logic out * of `packages/spec` and the frozen vocabulary is not this unit's to widen. * - * ## Today it emits nothing + * ## What it emits today * - * Publish/validate still rejects a non-empty `apis:` until the E7 flip, so the - * enumeration yields an empty set and {@link enrichOpenApiWithEndpoints} - * returns its input document BY REFERENCE — the served bytes are identical to - * before this change. That invariant is pinned by a test rather than argued. + * Real documents. The #5040 E7 publish flip + * (`packages/spec/src/api/endpoint-publish-gate.ts`) ended the wholesale + * refusal of a non-empty `apis:`, so the enumeration is no longer empty on a + * deployment that declares endpoints: a real showcase boot serves an + * `/openapi.json` describing its two declared endpoints + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`). + * + * The empty-set case is still exact rather than approximate — with nothing to + * add, {@link enrichOpenApiWithEndpoints} returns its input document BY + * REFERENCE — which is what keeps a deployment that declares no endpoint + * byte-identical to one built before this module existed. That invariant is + * pinned by a test rather than argued. */ import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; @@ -328,8 +336,10 @@ export function selectDocumentableEndpoints( * Fold declared endpoints into an OpenAPI document's `paths`. * * Returns `doc` ITSELF when there is nothing to add — that is what makes the - * empty-set case byte-identical rather than merely equivalent, and it is the - * state of the world until the E7 flip lets a non-empty `apis:` publish. + * empty-set case byte-identical rather than merely equivalent, which is the + * state a deployment declaring no endpoint stays in. Since the E7 flip let a + * non-empty `apis:` publish, the other branch is the live one wherever + * endpoints are declared. * * A declaration never displaces a built-in: if the document already describes * the same path+method, the built-in keeps it and the declaration is reported. diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts index 33488b2e13..4c65e8dd3c 100644 --- a/packages/rest/src/rest-openapi-route.test.ts +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -77,9 +77,11 @@ describe('GET /api/v1/openapi.json — endpoint enrichment', () => { }); it('serves a document identical to the pre-#5093 one while no endpoint is declared', async () => { - // The load-bearing invariant: publish rejects a non-empty `apis:` until the - // E7 flip, so this is the ONLY state that exists in production today, and - // the change is required to be invisible in it. Compared against the same + // The load-bearing invariant: a deployment that declares no endpoint must + // not be able to tell the enrichment step exists. Since the #5040 E7 + // publish flip this is no longer the only state in production — endpoints + // do publish — which is exactly why the no-declaration state needs pinning + // rather than assuming. Compared against the same // handler fed a protocol with no `api` capability at all — i.e. the world // exactly as it was before the enrichment step existed. const withEmptyApis = await serveOpenApi(makeProtocol({ object: [], api: [] }).protocol); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a0b23d19d4..910b0c1b8a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2862,10 +2862,12 @@ export class RestServer { // ADR-0076): declared endpoints join it here rather than // growing a second generator somewhere else. // - // Until the E7 flip a non-empty `apis:` cannot publish, so - // the enumeration is empty and `enrichOpenApiWithEndpoints` - // hands `enriched` straight back — the served bytes today - // are exactly the ones served before this change. + // Since the E7 flip a non-empty `apis:` publishes, so this + // enumeration returns real declarations on a deployment that + // has them and the document grows a path entry per endpoint. + // Where nothing is declared the enumeration is empty and + // `enrichOpenApiWithEndpoints` hands `enriched` straight + // back, byte for byte. try { const apiResult = await protocol?.getMetaItems?.({ type: 'api' }); const apiItems: unknown[] = Array.isArray((apiResult as any)?.items) diff --git a/packages/runtime/src/api-endpoint-step.test.ts b/packages/runtime/src/api-endpoint-step.test.ts index 3863cb4003..06d9ad3d88 100644 --- a/packages/runtime/src/api-endpoint-step.test.ts +++ b/packages/runtime/src/api-endpoint-step.test.ts @@ -5,8 +5,10 @@ * * Every case here is about ONE question: when does this step answer, and when * does it write nothing so the transport's existing unmatched answer stands? - * Getting that wrong in either direction is a live behavior change on a surface - * that is supposed to be inert until the #5040 E7 flip. + * Getting that wrong in either direction is a live behavior change — and since + * the #5040 E7 publish flip it is live for real traffic, not just in principle: + * endpoints can be declared, so a step that answers when it should stay silent + * now shadows the transport's 404 on a deployment. * * `matchEndpoint` is driven by a stub implementing the contract in * `@objectstack/spec/contracts` — deliberately, not by the real matcher: that diff --git a/packages/runtime/src/api-endpoint-step.ts b/packages/runtime/src/api-endpoint-step.ts index 73b79eef9c..36b4156b2b 100644 --- a/packages/runtime/src/api-endpoint-step.ts +++ b/packages/runtime/src/api-endpoint-step.ts @@ -20,11 +20,14 @@ * ## What it does today, and what it does not * * On a match it runs the WHOLE chain: policies (#5040 E4) and then target - * execution (#5040 E5), wired together here by E5b. It is still structurally - * unreachable — a non-empty `apis:` is rejected at publish / validate until the - * E7 flip — so no deployment can observe it; the tests drive `matchEndpoint` - * through a stub, exactly as #5040 §5 prescribes for every E-series unit that - * lands before the flip. + * execution (#5040 E5), wired together here by E5b. Since the E7 publish flip + * (`packages/spec/src/api/endpoint-publish-gate.ts`) a non-empty `apis:` is no + * longer refused wholesale — only shape by shape — so a deployment CAN observe + * this step: the showcase declares two endpoints and a real boot serves them + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`). + * The tests here still drive `matchEndpoint` through a stub, which is what + * keeps the decision (when to answer, when to write nothing) testable apart + * from the wiring. * * ## The chain, in the one order it can run in * diff --git a/packages/runtime/src/api-mapping.test.ts b/packages/runtime/src/api-mapping.test.ts index fe92cb7d52..32686cc5b4 100644 --- a/packages/runtime/src/api-mapping.test.ts +++ b/packages/runtime/src/api-mapping.test.ts @@ -14,9 +14,10 @@ * applied. `transform` is the case #5137 was filed over, but an unusable * path and colliding targets are the same category and answer identically. * - * And the property that makes both safe to land before the E7 flip: with no - * declaration, the value that goes in is the value that comes out, by - * reference. + * And the identity property both rest on: with no declaration, the value that + * goes in is the value that comes out, by reference. That is what made these + * keys safe to land ahead of the #5040 E7 publish flip, and it is still what + * keeps an endpoint declaring no mapping free of any projection cost. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts index a2a47eea0c..65f5624b15 100644 --- a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts @@ -18,9 +18,12 @@ * rides a success and never an error. * * The load-bearing assertion in most of these is a NEGATIVE one: that adding - * this seam changed nothing for anybody. Today's unmatched answers — the bare - * 404 and the 405 + `Allow` — must come back byte for byte, since a stack - * cannot declare an endpoint at all until the E7 flip. + * this seam changed nothing for anybody who did not ask for it. The unmatched + * answers — the bare 404 and the 405 + `Allow` — must come back byte for byte + * for every path no declaration owns. Since the #5040 E7 publish flip that is + * the assertion's whole weight: stacks CAN declare endpoints now, so "the + * fallback stays silent unless a declaration matches" is a promise to live + * deployments rather than a property of a surface nothing could reach. * * NOTE on the body guarantee: that the fallback receives a READABLE `req.body` * (the difference from the `use()` middleware seam) is a transport promise, and diff --git a/packages/runtime/src/dispatcher-plugin.multi-tenant-endpoint.integration.test.ts b/packages/runtime/src/dispatcher-plugin.multi-tenant-endpoint.integration.test.ts new file mode 100644 index 0000000000..9d971096b2 --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.multi-tenant-endpoint.integration.test.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The declarative-endpoint fallback on a MULTI-TENANT host (#5040 E5b, #5399). + * + * ## The branch this file exists for + * + * `dispatcher-plugin.ts`'s `setFallbackHandler` asks the host's resolver to + * place each request in an environment BEFORE it probes `matchEndpoint`. When + * that placement fails on a multi-tenant host it DECLINES — writes nothing, so + * the transport's own 404 stands — and warns. Serving it from the default + * kernel instead would answer one tenant's URL out of another tenant's data. + * + * Until #5399 that branch had zero test references anywhere in the repo, for a + * structural reason rather than an oversight: the `kernel-resolver` PROVIDER + * ships in the cloud distribution, so this repo holds only consumers and no + * real boot here ever resolves a second environment. A stub resolver is the + * only way to reach it — the same posture + * `http-dispatcher.multi-tenant-concurrency.test.ts` (#5155) takes, moved up a + * layer: that file drives `HttpDispatcher` directly, this one drives the + * composed plugin over a real socket, because the branch under test lives in + * the plugin's fallback closure and not in the dispatcher at all. + * + * ## Why a real boot rather than calling the closure + * + * The handler is a closure created inside `start()`; no seam hands it to a + * caller. Reaching it any other way would mean re-implementing the + * composition, which is the shortcut ADR-0076's "who serves this path" note + * (and #4073, answered wrongly three times) says not to take. + * + * ## The topology, and why the HOST also declares the endpoint + * + * Every kernel here — the two tenants AND the host — carries a `metadata` slot + * declaring the same path, and an `automation` slot tagged with its owner. The + * host's copy is what gives the decline branch something to prevent: a request + * that cannot be placed keeps `context.kernel` as the DEFAULT kernel, so + * without the branch the fallback would probe the host's declaration, match it, + * and answer 200 out of the host — one tenant's URL served from another + * kernel's data. + * + * That second copy is not decoration; it is the difference between a test and a + * tautology. The first draft of this file declared the endpoint on the tenants + * only, and reverse-verification (branch deleted, suite re-run) showed the + * decline cases stayed GREEN: with nothing declared on the host the step simply + * declined again for an unrelated reason — no `metadata` slot on the request's + * kernel — so the 404 and the never-probed assertions proved nothing, and only + * the `warn` moved. The topology below is the one in which every assertion + * moves. + * + * Service resolution reaches those copies by two different routes, which is + * also why both cases are meaningful: + * + * - `metadata` is resolved WITH the environment id. The host-scoped lookup it + * tries first answers nothing on a LiteKernel (no scoped factory is + * registered, unlike the cloud host this models), so it falls through to the + * request's own kernel — the tenant's copy on a placed request, the host's + * copy on an unplaced one. + * - `automation` is resolved WITHOUT one and reads `context.kernel` directly. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { LiteKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpointMatch, IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const PATH = '/api/v1/apps/showcase/tenant-probe'; +const FLOW = 'tenant_probe_flow'; + +/** + * The one declared endpoint. + * + * `authRequired: false` + an ARMED `rateLimit` is the single anonymous shape + * ADR-0121 D6 permits (an unarmed budget meters nothing, so the gate requires + * `enabled: true`). Declared that way here so the fixture is a shape publish + * would actually accept, rather than one that survives only because a stub + * matcher skips the gates. + */ +const ENDPOINT: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'tenant_probe', + path: PATH, + method: 'GET', + type: 'flow', + target: FLOW, + authRequired: false, + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 1_000 }, +}); + +/** Every `matchEndpoint` query the boot made, tagged with who answered it. */ +const matchQueries: Array<{ owner: string; path: string; method: string }> = []; + +/** One matcher body, so host and tenants differ only by their tag. */ +function makeMatcher(owner: string) { + return async (q: { path: string; method: string }): Promise => { + matchQueries.push({ owner, path: q.path, method: q.method }); + const hit = q.path.replace(/\/$/, '') === ENDPOINT.path && q.method.toUpperCase() === ENDPOINT.method; + return hit ? { endpoint: ENDPOINT, params: {} } : undefined; + }; +} + +/** + * The HOST's `metadata` + `automation` slots — the counterfactual. + * + * A placed request must never reach these; an unplaced one would reach both if + * the decline branch were gone, and would come back 200 saying `servedBy: + * 'host'`. Occupying `automation` too is what makes that failure mode a plain + * wrong answer rather than a 501 that could be mistaken for an unrelated gap. + */ +function hostSlotsPlugin(): Plugin { + return { + name: 'com.objectstack.test.host-slots', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('metadata', { list: async () => [], matchEndpoint: makeMatcher('host') }); + ctx.registerService('automation', { + execute: async (flow: string) => ({ servedBy: 'host', flow }), + }); + }, + }; +} + +/** + * A per-environment kernel carrying only the two slots the chain reads. + * + * Smaller than a kernel on purpose: anything else it grew could be read by + * accident and would weaken the "served by THIS kernel" claim. + */ +function makeTenantKernel(tenant: string) { + const services: Record = { + metadata: { __tag: tenant, list: async () => [], matchEndpoint: makeMatcher(tenant) }, + automation: { __tag: tenant, execute: async (flow: string) => ({ servedBy: tenant, flow }) }, + }; + return { + __tag: tenant, + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + }; +} + +const TENANTS: Record> = { + 'env-1': makeTenantKernel('env-1'), + 'env-2': makeTenantKernel('env-2'), +}; + +/** Every environment id the resolver was asked to place. */ +const resolverCalls: Array = []; + +/** + * The host's `kernel-resolver` — the ADR-0006 Phase 5 seam the cloud + * distribution fills. Registering it at all is what makes + * `HttpDispatcher.isMultiTenantHost()` true, which is the branch's guard. + * + * Registered in `init()` because the dispatcher plugin resolves it during + * `start()` when it constructs the `HttpDispatcher`; a `start()`-registered + * resolver would be visible or not depending on plugin order. + * + * The decline is expressed exactly as the contract says: return `undefined` + * and leave `context.environmentId` unset. + */ +function tenantResolverPlugin(): Plugin { + return { + name: 'com.objectstack.test.kernel-resolver', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('kernel-resolver', { + resolveKernel: async (context: any) => { + const headers = (context?.request?.headers ?? {}) as Record; + const envId = headers['x-environment-id']; + resolverCalls.push(envId); + const kernel = envId ? TENANTS[envId] : undefined; + if (!kernel) return undefined; + context.environmentId = envId; + return kernel; + }, + }); + }, + }; +} + +let kernel: LiteKernel | undefined; +let baseUrl = ''; + +/** Capture the boot's log lines without swallowing them. */ +let stdoutLines: string[] = []; +let restoreStdout: (() => void) | undefined; + +function captureStdout() { + const original = process.stdout.write.bind(process.stdout); + const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any, ...rest: any[]) => { + stdoutLines.push(String(chunk)); + return (original as any)(chunk, ...rest); + }) as any); + restoreStdout = () => spy.mockRestore(); +} + +beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(hostSlotsPlugin()); + kernel.use(tenantResolverPlugin()); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); + await kernel.bootstrap(); + const httpServer = kernel.getService('http.server'); + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; +}, 60_000); + +afterAll(async () => { + restoreStdout?.(); + if (!kernel) return; + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); +}, 60_000); + +describe('#5040 E5b — a placed request executes on ITS OWN resolved kernel', () => { + it('delegates to the resolved tenant\'s automation slot, not the host\'s', async () => { + matchQueries.length = 0; + + const res = await fetch(`${baseUrl}${PATH}`, { headers: { 'X-Environment-Id': 'env-1' } }); + + expect(res.status).toBe(200); + // `servedBy` can only be 'env-1' if the delegation read env-1's kernel. + // The host's own automation slot is occupied and must not answer. + expect(await res.json()).toEqual({ success: true, data: { servedBy: 'env-1', flow: FLOW } }); + expect(matchQueries).toEqual([{ owner: 'env-1', path: PATH, method: 'GET' }]); + }, 60_000); + + it('serves a second tenant the SAME path out of its own kernel', async () => { + matchQueries.length = 0; + + const res = await fetch(`${baseUrl}${PATH}`, { headers: { 'X-Environment-Id': 'env-2' } }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true, data: { servedBy: 'env-2', flow: FLOW } }); + expect(matchQueries).toEqual([{ owner: 'env-2', path: PATH, method: 'GET' }]); + }, 60_000); +}); + +describe('#5040 E5b — multi-tenant resolution finds no environment: decline + warn', () => { + it('answers the transport\'s own 404 and never probes the declaration', async () => { + matchQueries.length = 0; + resolverCalls.length = 0; + stdoutLines = []; + captureStdout(); + + // No `X-Environment-Id`: the resolver cannot place the request, so it + // returns undefined and leaves `environmentId` unset. + const res = await fetch(`${baseUrl}${PATH}`); + restoreStdout?.(); + + // The resolver WAS asked — this is the decline branch, not a + // single-environment host that never had a resolver. + expect(resolverCalls).toEqual([undefined]); + + // Declining writes nothing, so the bare Hono 404 stands. The host + // DECLARES this path and could have served it: without the branch this + // is a 200 carrying `servedBy: 'host'`. + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + + // The decline happened BEFORE the probe. A declaration must never be + // consulted for a request that was not placed in an environment. + expect(matchQueries).toEqual([]); + + const warned = stdoutLines.filter( + (line) => line.includes('WARN') && line.includes('resolved to no environment'), + ); + expect(warned).toHaveLength(1); + // The warning names the path, so an operator can tell WHICH request + // was dropped rather than only that one was. + expect(warned[0]).toContain(PATH); + expect(warned[0]).toContain('declining rather than serving it from the default kernel'); + }, 60_000); + + it('declines an UNKNOWN environment the same way — a header is not a placement', async () => { + matchQueries.length = 0; + resolverCalls.length = 0; + + const res = await fetch(`${baseUrl}${PATH}`, { headers: { 'X-Environment-Id': 'env-does-not-exist' } }); + + expect(resolverCalls).toEqual(['env-does-not-exist']); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + expect(matchQueries).toEqual([]); + }, 60_000); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 14f02a7f84..abf8aa47e1 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1426,16 +1426,22 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu ctx.logger.info('Declarative endpoint dispatch step armed', { mount: appEndpointMountPrefix(prefix), // True as of #5040 E5b: policy chain + target execution are - // wired. Nothing can be DECLARED until the E7 publish flip, so - // this still describes a surface no deployment can reach. + // wired. And reachable as of the E7 publish flip — a + // non-empty `apis:` publishes, so this describes a surface + // deployments actually serve. executes: true, }); } else { - // `debug`, not `warn`: no stack can declare an endpoint yet (a - // non-empty `apis:` is rejected at publish until #5040 E7), so - // nothing is missing from any deployment today. When that flip - // lands, THIS is where absence must become loud — an adapter - // without the seam can never serve a declared endpoint. + // Still `debug`, and that is now UNDER-STATED — tracked by + // #5400, deliberately not changed here (#5399 is comment-only). + // The reason this was debug has expired: it read "no stack can + // declare an endpoint yet", which stopped being true at the + // #5040 E7 publish flip. Declarations exist now, so on an + // adapter without this seam they are silently unservable and + // the operator's only signal is a line that the default `info` + // level does not even print. This is the "absence must be loud" + // case (AGENTS.md, Route & surface ownership §3); #5400 raises + // it to `warn` carrying the consequence and the remedy. ctx.logger.debug( '[dispatcher] http.server exposes no `setFallbackHandler`; declarative endpoints ' + 'would be unreachable on this transport.', diff --git a/packages/runtime/src/endpoint-executor.ts b/packages/runtime/src/endpoint-executor.ts index 3601dbadfb..f70a1999e0 100644 --- a/packages/runtime/src/endpoint-executor.ts +++ b/packages/runtime/src/endpoint-executor.ts @@ -20,18 +20,18 @@ * contributes is only: which pipeline, with which arguments, and how its answer * (or its throw) becomes an HTTP response. * - * ## Nothing calls this yet + * ## What calls this, and since when * - * The dispatch step (`api-endpoint-step.ts`, #5090) still answers 501 on a - * match. Replacing that branch with `policy → execute` is a deliberate - * follow-up single, landing after this and #5091 (E4, policy keys) are both on - * `main` — the two were developed in parallel on disjoint file surfaces (#4604), - * and wiring them together is one small reviewable change rather than a race - * between two PRs over the same lines. Until that lands this module is - * unreachable, and on top of that a non-empty `apis:` is still rejected at - * publish/validate until the E7 flip. Zero live behavior change; the tests drive - * it directly with stubs, exactly as #5040 §5 prescribes for every E-series unit - * that lands before the flip. + * The dispatch step (`api-endpoint-step.ts`, #5090) calls it on every match: + * E5b replaced that step's provisional 501 with `policy → execute`, so this + * module runs whenever a declared endpoint is matched. The E7 publish flip + * (`packages/spec/src/api/endpoint-publish-gate.ts`) then made declarations + * possible at all — a non-empty `apis:` is no longer refused wholesale, only + * shape by shape — so this is LIVE code on a real deployment, not a unit + * waiting for its seam. The showcase exercises it end to end over a socket + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`); + * the unit tests below still drive it directly with stubs, which is how the + * exact delegated call shape stays assertable. * * ## Pure by construction * diff --git a/packages/runtime/src/endpoint-policy.ts b/packages/runtime/src/endpoint-policy.ts index 7c0225a48c..1c25bdb409 100644 --- a/packages/runtime/src/endpoint-policy.ts +++ b/packages/runtime/src/endpoint-policy.ts @@ -46,11 +46,16 @@ * with anonymous traffic from the same address), then the meter runs, then the * gate. One lookup, used twice. * - * ## Structurally unreachable today + * ## Live on a real deployment * - * A non-empty `apis:` is rejected at publish / validate until the #5040 E7 flip, - * so nothing here can be observed by a deployment. The tests drive it directly, - * as #5040 §5 prescribes for every E-series unit landing before the flip. + * The #5040 E7 flip (`packages/spec/src/api/endpoint-publish-gate.ts`) ended + * the wholesale refusal of a non-empty `apis:`, so these keys now gate real + * traffic: `api-endpoint-step.ts` applies this chain on every match, and the + * showcase's two declared endpoints exercise it over a socket + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts` + * pins that `authRequired` denies anonymous and `cacheTtl` reaches the wire). + * The tests below still drive this module directly — a pure function over + * explicit deps is worth testing as one. */ import { diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 3a5b57f439..7b1731eea3 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -295,9 +295,12 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ + 'service. `script` / `proxy` targets and the inputMapping / outputMapping keys are NOT ' + 'executed and answer 501. A miss (or an occupant of the metadata slot with no matchEndpoint, ' + 'or a multi-tenant request that resolves to no environment) writes nothing, leaving the ' - + 'transport\'s 404/405 answer untouched. Structurally unreachable today: a non-empty `apis:` ' - + 'is rejected at publish until the #5040 E7 flip, so nothing can be declared for this seam to ' - + 'match. No SDK surface — app-declared endpoints are an external-integration channel ' + + 'transport\'s 404/405 answer untouched. LIVE since the #5040 E7 publish flip: a non-empty ' + + '`apis:` no longer fails wholesale, it is gated shape by shape ' + + '(`packages/spec/src/api/endpoint-publish-gate.ts`), so declarations exist and a real boot ' + + 'reaches this seam — the showcase serves two of them ' + + '(`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`). ' + + 'No SDK surface — app-declared endpoints are an external-integration channel ' + '(ADR-0121 D3), called by the integrator\'s own client, not by `@objectstack/client`', },