Skip to content

Commit 4280055

Browse files
os-justinclaude
andauthored
fix(runtime): mount the scoped /packages dispatcher door and reconcile its read/delete responses to the spec (#17433)
* fix(runtime): mount the scoped /packages dispatcher door and reconcile its read/delete responses `mountPackagesRoute` mounted `/packages*` at the unscoped prefix only, while automation / actions / ai each registered a scoped variant twenty lines away. On a host composed as `plugin-hono-server` + the dispatcher with `enableProjectScoping: true` and WITHOUT `@objectstack/hono`'s `createHonoApp`, that left `GET /api/v1/environments/:id/packages`, `GET .../packages/:id` and `DELETE .../packages/:id` answered by the transport's own `notFound`. The domain has resolved scoped package paths since #15859; nothing mounted one. Wrap `mountPackagesRoute` in a `base`-taking `registerPackageRoutes(base)`, the same shape its three siblings use, and call it a second time with the scoped base. Same handler, no second implementation. The unscoped mounts keep their registration position and stay unconditional, so the change is additive. Reconcile both doors to their declared schemas (contract review of #16628, F2), additively — no key leaves either payload: - `GET /packages` sends `hasMore` (false: this door reads no limit/cursor and returns every row that survives its filters). - `DELETE /packages/:id` sends `packageId`. Fill `responseSchema` on the `DELETE /packages/:id` ledger row, backed by new conformance coverage driving the real handler. `GET /packages` is left blank on purpose: its rows carry the ASSEMBLED package body while `InstalledPackageSchema` wraps the AUTHORING-stage `ManifestSchema` (the #14242 stage mismatch), so a name there would be false on every `defineStack()` host. Both directions of that boundary are pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt * fix(runtime): strip tracker ids from the route-ledger note string `check:doc-authoring` refuses new internal issue-id references in sibling-package string prose: a runtime string reaches authors, operators and generated surfaces, none of whom can resolve `#NNNN`. The ids move to the adjacent `//` comment, where the reader who CAN resolve them reads the source. Maintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9788f1e commit 4280055

7 files changed

Lines changed: 610 additions & 49 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
fix(runtime): mount the scoped `/api/v1/environments/:id/packages*` door, and reconcile the package read/delete responses to their declared schemas (#16781)
6+
7+
**The door.** `mountPackagesRoute` mounted `/packages*` at the unscoped prefix only, while automation / actions / ai each registered a scoped variant twenty lines away. On a host composed as `@objectstack/plugin-hono-server` + this plugin with `enableProjectScoping: true` and **without** `@objectstack/hono`'s `createHonoApp`, that left `GET /api/v1/environments/:id/packages`, `GET …/packages/:id` and `DELETE …/packages/:id` answered by the transport's own `notFound` — a bare 404 on routes `content/docs/api/environment-routing.mdx` documents. The domain has resolved scoped package paths since #15859; nothing mounted one.
8+
9+
`mountPackagesRoute` is now wrapped in a `base`-taking `registerPackageRoutes(base)`, exactly like its three siblings, and called a second time with the scoped base. **The same handler, no second implementation.** The unscoped mounts keep their registration position and their unconditional mounting, so the change is purely additive: no route that answered before stops answering.
10+
11+
**The wire.** Two responses gained the key their own declared schema requires (contract review of #16628, finding F2). Both additions are **additive** — no key left either payload:
12+
13+
- `GET /packages` now sends **`hasMore`** (`ListInstalledPackagesResponseSchema`). It is `false`: this door applies its `status` / `type` filters and returns every remaining row, reading no `limit` and no `cursor`, so there is no next page to announce.
14+
- `DELETE /packages/:id` now sends **`packageId`** (`UninstallPackageApiResponseSchema`). `registryRemoved` and `persisted` stay on the wire unchanged.
15+
16+
A client that reads only the keys it read before is unaffected; a client parsing either payload against the published schema stops being refused.
17+
18+
The `DELETE /packages/:id` route-ledger row now carries `responseSchema: 'UninstallPackageApiResponseSchema'`, backed by new conformance coverage that drives the real handler. `GET /packages` is deliberately left blank: its rows are the ASSEMBLED package body, while `InstalledPackageSchema` wraps the AUTHORING-stage `ManifestSchema` — the #14242 stage mismatch, which no `@objectstack/spec/api` export declares yet. Both directions of that boundary are pinned, so the row becomes fillable against a red test rather than a guess.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16781 — the scoped `/packages` door on a `plugin-hono-server`-only host.
5+
*
6+
* ## The composition this file exists for
7+
*
8+
* A host composed as `plugin-hono-server` + the dispatcher with
9+
* `enableProjectScoping: true`, and **without** `@objectstack/hono`'s
10+
* `createHonoApp`, has exactly two ways a request can reach the `/packages`
11+
* domain: an explicit route this plugin mounts, or `createHonoApp`'s
12+
* `app.all(`${prefix}/*`)` catch-all — which this composition does not have.
13+
* `setFallbackHandler` is not a third: it is gated on `isAppEndpointPath`, so
14+
* a scoped package URL never reaches it.
15+
*
16+
* Before #16781 the plugin mounted `/packages*` at the UNSCOPED prefix only —
17+
* automation / actions / ai each had a scoped variant twenty lines away and
18+
* packages had none — so `GET /api/v1/environments/:id/packages` on this
19+
* composition was answered by the transport's own `notFound`. The domain
20+
* itself has handled scoped paths since #15859
21+
* (`packages-single-door.test.ts` pins that half); what was missing was the
22+
* MOUNT, and only a test that boots this composition over a real socket can
23+
* see the difference.
24+
*
25+
* ## The acceptance control, verbatim from the card
26+
*
27+
* > the same request on the same composition answers `ROUTE_NOT_FOUND`/bare
28+
* > 404 before and the dispatcher's row after.
29+
*
30+
* `dispatcherAnswered()` below is the discriminator, and it is a positive
31+
* test rather than "not a 404": the anonymous-deny floor (#7033/#7023) is the
32+
* FIRST statement in `handlePackagesRequest`, ahead of the registry probe, so
33+
* a credential-less request that REACHES the dispatcher is answered
34+
* `ANONYMOUS_DENY_STATUS` / `ANONYMOUS_DENY_CODE` — a verdict no
35+
* transport-level sink emits, since an unmounted path never gets past
36+
* `notFound`. The two constants are IMPORTED rather than spelled, so a rename
37+
* moves this file with them instead of quietly turning the discriminator into
38+
* a literal that nothing produces. The negative control immediately below
39+
* drives an unmounted sibling path through the same assertion and shows it
40+
* answering the transport's own 404 instead, so the discriminator is measured
41+
* in both directions rather than assumed.
42+
*
43+
* ## Why no credentials
44+
*
45+
* The claim under test is "a door exists here", and the anonymous floor is the
46+
* earliest observable proof of arrival — earlier than the 503 an unprovisioned
47+
* registry would give, and it cannot be produced by the transport. Provisioning
48+
* an authenticated caller would move the assertion downstream of two more gates
49+
* without making it say more about the mount. The RESPONSE SHAPES this card
50+
* also reconciles are pinned where they can be parsed against the spec, in
51+
* `domains/packages-read-delete-response-conformance.test.ts`.
52+
*/
53+
54+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
55+
import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS, LiteKernel } from '@objectstack/core';
56+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
57+
import type { IHttpServer } from '@objectstack/spec/contracts';
58+
59+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
60+
61+
const PREFIX = '/api/v1';
62+
const ENV_ID = 'env_alpha';
63+
const PKG_ID = 'com.acme.crm';
64+
65+
let kernel: LiteKernel | undefined;
66+
let baseUrl = '';
67+
68+
/**
69+
* The composition named on the card: the hono TRANSPORT plugin plus the
70+
* dispatcher, scoping on. No `createHonoApp`, no `@objectstack/rest`, and no
71+
* service plugins — nothing here may supply a second door.
72+
*/
73+
beforeAll(async () => {
74+
kernel = new LiteKernel();
75+
kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
76+
kernel.use(createDispatcherPlugin({
77+
prefix: PREFIX,
78+
scoping: { enableProjectScoping: true, projectResolution: 'auto' },
79+
enforceProjectMembership: false,
80+
securityHeaders: false,
81+
}));
82+
await kernel.bootstrap();
83+
const httpServer = kernel.getService<IHttpServer>('http.server');
84+
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
85+
}, 60_000);
86+
87+
afterAll(async () => {
88+
if (!kernel) return;
89+
await Promise.race([
90+
kernel.shutdown(),
91+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
92+
]);
93+
}, 60_000);
94+
95+
async function probe(method: string, path: string): Promise<{ status: number; body: any }> {
96+
const res = await fetch(`${baseUrl}${path}`, { method });
97+
let body: any;
98+
try { body = await res.json(); } catch { body = undefined; }
99+
return { status: res.status, body };
100+
}
101+
102+
/**
103+
* Did the DISPATCHER answer this request?
104+
*
105+
* The anonymous deny is minted inside `dispatcher.dispatch()` and by nothing
106+
* in the transport, so a true reading here means the request crossed the
107+
* mount. This is the card's "the dispatcher's row", stated as the thing that
108+
* is observable without credentials.
109+
*/
110+
function dispatcherAnswered(r: { status: number; body: any }): boolean {
111+
return r.status === ANONYMOUS_DENY_STATUS && r.body?.error?.code === ANONYMOUS_DENY_CODE;
112+
}
113+
114+
/** The shape "no door answered" takes on this transport. */
115+
function transportRefused(r: { status: number; body: any }): boolean {
116+
const code = r.body?.error?.code;
117+
return r.status === 404 && (code === undefined || code === 'ROUTE_NOT_FOUND' || code === 'ENDPOINT_NOT_FOUND');
118+
}
119+
120+
const SCOPED = `${PREFIX}/environments/${ENV_ID}/packages`;
121+
const UNSCOPED = `${PREFIX}/packages`;
122+
123+
/** The three routes the card names, plus the verb each is reached by. */
124+
const CARD_ROUTES: Array<[string, string]> = [
125+
['GET', ''],
126+
['GET', `/${PKG_ID}`],
127+
['DELETE', `/${PKG_ID}`],
128+
];
129+
130+
describe('#16781 — the discriminator itself, measured in both directions', () => {
131+
it('POSITIVE CONTROL: the UNSCOPED door has always existed and answers from the domain', async () => {
132+
for (const [method, sub] of CARD_ROUTES) {
133+
const r = await probe(method, `${UNSCOPED}${sub}`);
134+
expect(dispatcherAnswered(r), `${method} ${UNSCOPED}${sub} -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
135+
}
136+
}, 60_000);
137+
138+
it('NEGATIVE CONTROL: a scoped path no mount claims answers the transport, not the domain', async () => {
139+
const r = await probe('GET', `${PREFIX}/environments/${ENV_ID}/no-such-domain`);
140+
expect(dispatcherAnswered(r)).toBe(false);
141+
expect(transportRefused(r), `unmounted sibling -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
142+
}, 60_000);
143+
});
144+
145+
describe('#16781 — the scoped /packages door on a plugin-hono-server-only composition', () => {
146+
for (const [method, sub] of CARD_ROUTES) {
147+
it(`${method} ${SCOPED}${sub} answers through the dispatcher`, async () => {
148+
const r = await probe(method, `${SCOPED}${sub}`);
149+
expect(
150+
dispatcherAnswered(r),
151+
`${method} ${SCOPED}${sub} -> ${r.status} ${JSON.stringify(r.body)}`,
152+
).toBe(true);
153+
}, 60_000);
154+
}
155+
});

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 87 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,52 +1298,78 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
12981298
// directly, which skipped that pipeline entirely and dropped
12991299
// req.query on several routes (so the documented `?overwrite=true`
13001300
// install flag never reached the handler).
1301-
const mountPackagesRoute = (
1302-
verb: 'get' | 'post' | 'patch' | 'delete',
1303-
routePath: string,
1304-
toSubPath: (req: any) => string,
1305-
) => {
1306-
(server as any)[verb](`${prefix}/packages${routePath}`, async (req: any, res: any) => {
1307-
try {
1308-
const result = await dispatcher.dispatch(
1309-
verb.toUpperCase(),
1310-
`/packages${toSubPath(req)}`,
1311-
req.body,
1312-
req.query ?? {},
1313-
{ request: req },
1314-
);
1315-
sendResult(result, res);
1316-
} catch (err: any) {
1317-
errorResponse(err, res);
1318-
}
1319-
});
1301+
//
1302+
// [#16781] A `base`-taking registrar, exactly like
1303+
// `registerAutomationRoutes` / `registerActionRoutes` /
1304+
// `registerAIRoutes` below, so the SAME handler can be mounted at
1305+
// the environment-scoped prefix as well. It used to close over
1306+
// `prefix` directly, and that is the whole reason
1307+
// `/api/v1/environments/:id/packages` had no door on a host
1308+
// composed as `plugin-hono-server` + this plugin WITHOUT
1309+
// `@objectstack/hono`'s catch-all: the domain has resolved scoped
1310+
// package paths since #15859, but nothing mounted one here. The
1311+
// scoped registration is at the `enableProjectScoping` block below,
1312+
// beside its three siblings; `dispatch()` is still handed the
1313+
// UNSCOPED subpath, and the `:environmentId` rides on `req.params`
1314+
// for `prepareResolverHints` to read — the same convention the
1315+
// action routes document.
1316+
const registerPackageRoutes = (base: string) => {
1317+
const mountPackagesRoute = (
1318+
verb: 'get' | 'post' | 'patch' | 'delete',
1319+
routePath: string,
1320+
toSubPath: (req: any) => string,
1321+
) => {
1322+
(server as any)[verb](`${base}/packages${routePath}`, async (req: any, res: any) => {
1323+
try {
1324+
const result = await dispatcher.dispatch(
1325+
verb.toUpperCase(),
1326+
`/packages${toSubPath(req)}`,
1327+
req.body,
1328+
req.query ?? {},
1329+
{ request: req },
1330+
);
1331+
sendResult(result, res);
1332+
} catch (err: any) {
1333+
errorResponse(err, res);
1334+
}
1335+
});
1336+
};
1337+
1338+
mountPackagesRoute('get', '', () => '');
1339+
mountPackagesRoute('post', '', () => '');
1340+
mountPackagesRoute('get', '/:id/export', (req) => `/${req.params.id}/export`);
1341+
mountPackagesRoute('get', '/:id', (req) => `/${req.params.id}`);
1342+
mountPackagesRoute('delete', '/:id', (req) => `/${req.params.id}`);
1343+
// Edit a package's manifest (name / description / version). `/:id`
1344+
// is a single segment, so this does not shadow the
1345+
// `/:id/enable|disable` routes below.
1346+
mountPackagesRoute('patch', '/:id', (req) => `/${req.params.id}`);
1347+
mountPackagesRoute('patch', '/:id/enable', (req) => `/${req.params.id}/enable`);
1348+
mountPackagesRoute('patch', '/:id/disable', (req) => `/${req.params.id}/disable`);
1349+
mountPackagesRoute('post', '/:id/publish', (req) => `/${req.params.id}/publish`);
1350+
// ADR-0033 — publish every pending draft bound to a package ("publish
1351+
// whole app"). Distinct from /publish (which needs the metadata
1352+
// service): this promotes sys_metadata draft rows via the protocol.
1353+
mountPackagesRoute('post', '/:id/publish-drafts', (req) => `/${req.params.id}/publish-drafts`);
1354+
mountPackagesRoute('post', '/:id/revert', (req) => `/${req.params.id}/revert`);
1355+
// duplicate (ADR-0070 D4), adopt-orphans (D5), discard-drafts, and
1356+
// the ADR-0067 commit-history / rollback family.
1357+
mountPackagesRoute('post', '/:id/duplicate', (req) => `/${req.params.id}/duplicate`);
1358+
mountPackagesRoute('post', '/:id/adopt-orphans', (req) => `/${req.params.id}/adopt-orphans`);
1359+
mountPackagesRoute('post', '/:id/discard-drafts', (req) => `/${req.params.id}/discard-drafts`);
1360+
mountPackagesRoute('get', '/:id/commits', (req) => `/${req.params.id}/commits`);
1361+
mountPackagesRoute('post', '/:id/commits/:commitId/revert', (req) => `/${req.params.id}/commits/${req.params.commitId}/revert`);
1362+
mountPackagesRoute('post', '/:id/rollback', (req) => `/${req.params.id}/rollback`);
13201363
};
13211364

1322-
mountPackagesRoute('get', '', () => '');
1323-
mountPackagesRoute('post', '', () => '');
1324-
mountPackagesRoute('get', '/:id/export', (req) => `/${req.params.id}/export`);
1325-
mountPackagesRoute('get', '/:id', (req) => `/${req.params.id}`);
1326-
mountPackagesRoute('delete', '/:id', (req) => `/${req.params.id}`);
1327-
// Edit a package's manifest (name / description / version). `/:id`
1328-
// is a single segment, so this does not shadow the
1329-
// `/:id/enable|disable` routes below.
1330-
mountPackagesRoute('patch', '/:id', (req) => `/${req.params.id}`);
1331-
mountPackagesRoute('patch', '/:id/enable', (req) => `/${req.params.id}/enable`);
1332-
mountPackagesRoute('patch', '/:id/disable', (req) => `/${req.params.id}/disable`);
1333-
mountPackagesRoute('post', '/:id/publish', (req) => `/${req.params.id}/publish`);
1334-
// ADR-0033 — publish every pending draft bound to a package ("publish
1335-
// whole app"). Distinct from /publish (which needs the metadata
1336-
// service): this promotes sys_metadata draft rows via the protocol.
1337-
mountPackagesRoute('post', '/:id/publish-drafts', (req) => `/${req.params.id}/publish-drafts`);
1338-
mountPackagesRoute('post', '/:id/revert', (req) => `/${req.params.id}/revert`);
1339-
// duplicate (ADR-0070 D4), adopt-orphans (D5), discard-drafts, and
1340-
// the ADR-0067 commit-history / rollback family.
1341-
mountPackagesRoute('post', '/:id/duplicate', (req) => `/${req.params.id}/duplicate`);
1342-
mountPackagesRoute('post', '/:id/adopt-orphans', (req) => `/${req.params.id}/adopt-orphans`);
1343-
mountPackagesRoute('post', '/:id/discard-drafts', (req) => `/${req.params.id}/discard-drafts`);
1344-
mountPackagesRoute('get', '/:id/commits', (req) => `/${req.params.id}/commits`);
1345-
mountPackagesRoute('post', '/:id/commits/:commitId/revert', (req) => `/${req.params.id}/commits/${req.params.commitId}/revert`);
1346-
mountPackagesRoute('post', '/:id/rollback', (req) => `/${req.params.id}/rollback`);
1365+
// Mounted at the UNSCOPED prefix right here, keeping the exact
1366+
// registration ORDER these routes have always had — Hono resolves
1367+
// competing patterns first-registration-wins (the ADR-0076 D11
1368+
// hazard this file's fallback note explains), so moving this call
1369+
// down beside the scoped one would be a behaviour change wearing a
1370+
// refactor's clothes. The scoped mount is purely ADDITIVE and
1371+
// cannot shadow anything: it lives under a different path prefix.
1372+
registerPackageRoutes(prefix);
13471373

13481374
// ── Storage ─────────────────────────────────────────────────
13491375
// Nothing mounted here on purpose (#4087). The dispatcher used to
@@ -1701,6 +1727,23 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
17011727
}
17021728
}
17031729

1730+
// [#16781] The scoped `/packages` door, the residue PR #16628 was
1731+
// authorised to leave behind (ruling C′ on #14503 step 2). Same
1732+
// handler as the unscoped mount above — `registerPackageRoutes` is
1733+
// called a second time with the scoped base, never re-implemented.
1734+
//
1735+
// ONE condition rather than the three-way branch its siblings take,
1736+
// and the difference is deliberate: `registerAutomationRoutes` /
1737+
// `registerActionRoutes` / `registerAIRoutes` DROP their unscoped
1738+
// mounts under `projectResolution: 'required'`, while the package
1739+
// routes above are mounted unconditionally and stay that way. This
1740+
// card adds a missing door; taking one away is a different change
1741+
// with a different blast radius, so the asymmetry is left standing
1742+
// and recorded here rather than silently "tidied" into a removal.
1743+
if (enableProjectScoping) {
1744+
registerPackageRoutes(`${prefix}/environments/:environmentId`);
1745+
}
1746+
17041747
ctx.logger.info('Dispatcher bridge routes registered', { prefix, enableProjectScoping, projectResolution });
17051748

17061749
// ── Declarative endpoint mount seam (#5040 E3) ───────────────

0 commit comments

Comments
 (0)