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
18 changes: 18 additions & 0 deletions .changeset/scoped-packages-dispatcher-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/runtime": minor
---

fix(runtime): mount the scoped `/api/v1/environments/:id/packages*` door, and reconcile the package read/delete responses to their declared schemas (#16781)

**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.

`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.

**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:

- `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.
- `DELETE /packages/:id` now sends **`packageId`** (`UninstallPackageApiResponseSchema`). `registryRemoved` and `persisted` stay on the wire unchanged.

A client that reads only the keys it read before is unaffected; a client parsing either payload against the published schema stops being refused.

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #16781 — the scoped `/packages` door on a `plugin-hono-server`-only host.
*
* ## The composition this file exists for
*
* A host composed as `plugin-hono-server` + the dispatcher with
* `enableProjectScoping: true`, and **without** `@objectstack/hono`'s
* `createHonoApp`, has exactly two ways a request can reach the `/packages`
* domain: an explicit route this plugin mounts, or `createHonoApp`'s
* `app.all(`${prefix}/*`)` catch-all — which this composition does not have.
* `setFallbackHandler` is not a third: it is gated on `isAppEndpointPath`, so
* a scoped package URL never reaches it.
*
* Before #16781 the plugin mounted `/packages*` at the UNSCOPED prefix only —
* automation / actions / ai each had a scoped variant twenty lines away and
* packages had none — so `GET /api/v1/environments/:id/packages` on this
* composition was answered by the transport's own `notFound`. The domain
* itself has handled scoped paths since #15859
* (`packages-single-door.test.ts` pins that half); what was missing was the
* MOUNT, and only a test that boots this composition over a real socket can
* see the difference.
*
* ## The acceptance control, verbatim from the card
*
* > the same request on the same composition answers `ROUTE_NOT_FOUND`/bare
* > 404 before and the dispatcher's row after.
*
* `dispatcherAnswered()` below is the discriminator, and it is a positive
* test rather than "not a 404": the anonymous-deny floor (#7033/#7023) is the
* FIRST statement in `handlePackagesRequest`, ahead of the registry probe, so
* a credential-less request that REACHES the dispatcher is answered
* `ANONYMOUS_DENY_STATUS` / `ANONYMOUS_DENY_CODE` — a verdict no
* transport-level sink emits, since an unmounted path never gets past
* `notFound`. The two constants are IMPORTED rather than spelled, so a rename
* moves this file with them instead of quietly turning the discriminator into
* a literal that nothing produces. The negative control immediately below
* drives an unmounted sibling path through the same assertion and shows it
* answering the transport's own 404 instead, so the discriminator is measured
* in both directions rather than assumed.
*
* ## Why no credentials
*
* The claim under test is "a door exists here", and the anonymous floor is the
* earliest observable proof of arrival — earlier than the 503 an unprovisioned
* registry would give, and it cannot be produced by the transport. Provisioning
* an authenticated caller would move the assertion downstream of two more gates
* without making it say more about the mount. The RESPONSE SHAPES this card
* also reconciles are pinned where they can be parsed against the spec, in
* `domains/packages-read-delete-response-conformance.test.ts`.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS, LiteKernel } from '@objectstack/core';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
import type { IHttpServer } from '@objectstack/spec/contracts';

import { createDispatcherPlugin } from './dispatcher-plugin.js';

const PREFIX = '/api/v1';
const ENV_ID = 'env_alpha';
const PKG_ID = 'com.acme.crm';

let kernel: LiteKernel | undefined;
let baseUrl = '';

/**
* The composition named on the card: the hono TRANSPORT plugin plus the
* dispatcher, scoping on. No `createHonoApp`, no `@objectstack/rest`, and no
* service plugins — nothing here may supply a second door.
*/
beforeAll(async () => {
kernel = new LiteKernel();
kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
kernel.use(createDispatcherPlugin({
prefix: PREFIX,
scoping: { enableProjectScoping: true, projectResolution: 'auto' },
enforceProjectMembership: false,
securityHeaders: false,
}));
await kernel.bootstrap();
const httpServer = kernel.getService<IHttpServer>('http.server');
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
}, 60_000);

afterAll(async () => {
if (!kernel) return;
await Promise.race([
kernel.shutdown(),
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
]);
}, 60_000);

async function probe(method: string, path: string): Promise<{ status: number; body: any }> {
const res = await fetch(`${baseUrl}${path}`, { method });
let body: any;
try { body = await res.json(); } catch { body = undefined; }
return { status: res.status, body };
}

/**
* Did the DISPATCHER answer this request?
*
* The anonymous deny is minted inside `dispatcher.dispatch()` and by nothing
* in the transport, so a true reading here means the request crossed the
* mount. This is the card's "the dispatcher's row", stated as the thing that
* is observable without credentials.
*/
function dispatcherAnswered(r: { status: number; body: any }): boolean {
return r.status === ANONYMOUS_DENY_STATUS && r.body?.error?.code === ANONYMOUS_DENY_CODE;
}

/** The shape "no door answered" takes on this transport. */
function transportRefused(r: { status: number; body: any }): boolean {
const code = r.body?.error?.code;
return r.status === 404 && (code === undefined || code === 'ROUTE_NOT_FOUND' || code === 'ENDPOINT_NOT_FOUND');
}

const SCOPED = `${PREFIX}/environments/${ENV_ID}/packages`;
const UNSCOPED = `${PREFIX}/packages`;

/** The three routes the card names, plus the verb each is reached by. */
const CARD_ROUTES: Array<[string, string]> = [
['GET', ''],
['GET', `/${PKG_ID}`],
['DELETE', `/${PKG_ID}`],
];

describe('#16781 — the discriminator itself, measured in both directions', () => {
it('POSITIVE CONTROL: the UNSCOPED door has always existed and answers from the domain', async () => {
for (const [method, sub] of CARD_ROUTES) {
const r = await probe(method, `${UNSCOPED}${sub}`);
expect(dispatcherAnswered(r), `${method} ${UNSCOPED}${sub} -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
}
}, 60_000);

it('NEGATIVE CONTROL: a scoped path no mount claims answers the transport, not the domain', async () => {
const r = await probe('GET', `${PREFIX}/environments/${ENV_ID}/no-such-domain`);
expect(dispatcherAnswered(r)).toBe(false);
expect(transportRefused(r), `unmounted sibling -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
}, 60_000);
});

describe('#16781 — the scoped /packages door on a plugin-hono-server-only composition', () => {
for (const [method, sub] of CARD_ROUTES) {
it(`${method} ${SCOPED}${sub} answers through the dispatcher`, async () => {
const r = await probe(method, `${SCOPED}${sub}`);
expect(
dispatcherAnswered(r),
`${method} ${SCOPED}${sub} -> ${r.status} ${JSON.stringify(r.body)}`,
).toBe(true);
}, 60_000);
}
});
131 changes: 87 additions & 44 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1298,52 +1298,78 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
// directly, which skipped that pipeline entirely and dropped
// req.query on several routes (so the documented `?overwrite=true`
// install flag never reached the handler).
const mountPackagesRoute = (
verb: 'get' | 'post' | 'patch' | 'delete',
routePath: string,
toSubPath: (req: any) => string,
) => {
(server as any)[verb](`${prefix}/packages${routePath}`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch(
verb.toUpperCase(),
`/packages${toSubPath(req)}`,
req.body,
req.query ?? {},
{ request: req },
);
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});
//
// [#16781] A `base`-taking registrar, exactly like
// `registerAutomationRoutes` / `registerActionRoutes` /
// `registerAIRoutes` below, so the SAME handler can be mounted at
// the environment-scoped prefix as well. It used to close over
// `prefix` directly, and that is the whole reason
// `/api/v1/environments/:id/packages` had no door on a host
// composed as `plugin-hono-server` + this plugin WITHOUT
// `@objectstack/hono`'s catch-all: the domain has resolved scoped
// package paths since #15859, but nothing mounted one here. The
// scoped registration is at the `enableProjectScoping` block below,
// beside its three siblings; `dispatch()` is still handed the
// UNSCOPED subpath, and the `:environmentId` rides on `req.params`
// for `prepareResolverHints` to read — the same convention the
// action routes document.
const registerPackageRoutes = (base: string) => {
const mountPackagesRoute = (
verb: 'get' | 'post' | 'patch' | 'delete',
routePath: string,
toSubPath: (req: any) => string,
) => {
(server as any)[verb](`${base}/packages${routePath}`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch(
verb.toUpperCase(),
`/packages${toSubPath(req)}`,
req.body,
req.query ?? {},
{ request: req },
);
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});
};

mountPackagesRoute('get', '', () => '');
mountPackagesRoute('post', '', () => '');
mountPackagesRoute('get', '/:id/export', (req) => `/${req.params.id}/export`);
mountPackagesRoute('get', '/:id', (req) => `/${req.params.id}`);
mountPackagesRoute('delete', '/:id', (req) => `/${req.params.id}`);
// Edit a package's manifest (name / description / version). `/:id`
// is a single segment, so this does not shadow the
// `/:id/enable|disable` routes below.
mountPackagesRoute('patch', '/:id', (req) => `/${req.params.id}`);
mountPackagesRoute('patch', '/:id/enable', (req) => `/${req.params.id}/enable`);
mountPackagesRoute('patch', '/:id/disable', (req) => `/${req.params.id}/disable`);
mountPackagesRoute('post', '/:id/publish', (req) => `/${req.params.id}/publish`);
// ADR-0033 — publish every pending draft bound to a package ("publish
// whole app"). Distinct from /publish (which needs the metadata
// service): this promotes sys_metadata draft rows via the protocol.
mountPackagesRoute('post', '/:id/publish-drafts', (req) => `/${req.params.id}/publish-drafts`);
mountPackagesRoute('post', '/:id/revert', (req) => `/${req.params.id}/revert`);
// duplicate (ADR-0070 D4), adopt-orphans (D5), discard-drafts, and
// the ADR-0067 commit-history / rollback family.
mountPackagesRoute('post', '/:id/duplicate', (req) => `/${req.params.id}/duplicate`);
mountPackagesRoute('post', '/:id/adopt-orphans', (req) => `/${req.params.id}/adopt-orphans`);
mountPackagesRoute('post', '/:id/discard-drafts', (req) => `/${req.params.id}/discard-drafts`);
mountPackagesRoute('get', '/:id/commits', (req) => `/${req.params.id}/commits`);
mountPackagesRoute('post', '/:id/commits/:commitId/revert', (req) => `/${req.params.id}/commits/${req.params.commitId}/revert`);
mountPackagesRoute('post', '/:id/rollback', (req) => `/${req.params.id}/rollback`);
};

mountPackagesRoute('get', '', () => '');
mountPackagesRoute('post', '', () => '');
mountPackagesRoute('get', '/:id/export', (req) => `/${req.params.id}/export`);
mountPackagesRoute('get', '/:id', (req) => `/${req.params.id}`);
mountPackagesRoute('delete', '/:id', (req) => `/${req.params.id}`);
// Edit a package's manifest (name / description / version). `/:id`
// is a single segment, so this does not shadow the
// `/:id/enable|disable` routes below.
mountPackagesRoute('patch', '/:id', (req) => `/${req.params.id}`);
mountPackagesRoute('patch', '/:id/enable', (req) => `/${req.params.id}/enable`);
mountPackagesRoute('patch', '/:id/disable', (req) => `/${req.params.id}/disable`);
mountPackagesRoute('post', '/:id/publish', (req) => `/${req.params.id}/publish`);
// ADR-0033 — publish every pending draft bound to a package ("publish
// whole app"). Distinct from /publish (which needs the metadata
// service): this promotes sys_metadata draft rows via the protocol.
mountPackagesRoute('post', '/:id/publish-drafts', (req) => `/${req.params.id}/publish-drafts`);
mountPackagesRoute('post', '/:id/revert', (req) => `/${req.params.id}/revert`);
// duplicate (ADR-0070 D4), adopt-orphans (D5), discard-drafts, and
// the ADR-0067 commit-history / rollback family.
mountPackagesRoute('post', '/:id/duplicate', (req) => `/${req.params.id}/duplicate`);
mountPackagesRoute('post', '/:id/adopt-orphans', (req) => `/${req.params.id}/adopt-orphans`);
mountPackagesRoute('post', '/:id/discard-drafts', (req) => `/${req.params.id}/discard-drafts`);
mountPackagesRoute('get', '/:id/commits', (req) => `/${req.params.id}/commits`);
mountPackagesRoute('post', '/:id/commits/:commitId/revert', (req) => `/${req.params.id}/commits/${req.params.commitId}/revert`);
mountPackagesRoute('post', '/:id/rollback', (req) => `/${req.params.id}/rollback`);
// Mounted at the UNSCOPED prefix right here, keeping the exact
// registration ORDER these routes have always had — Hono resolves
// competing patterns first-registration-wins (the ADR-0076 D11
// hazard this file's fallback note explains), so moving this call
// down beside the scoped one would be a behaviour change wearing a
// refactor's clothes. The scoped mount is purely ADDITIVE and
// cannot shadow anything: it lives under a different path prefix.
registerPackageRoutes(prefix);

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

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

ctx.logger.info('Dispatcher bridge routes registered', { prefix, enableProjectScoping, projectResolution });

// ── Declarative endpoint mount seam (#5040 E3) ───────────────
Expand Down
Loading
Loading