From 67e1f910957f24385391b420070e7c26e4b7ac61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:15:57 +0000 Subject: [PATCH] fix(runtime): mount GET /packages/:id/customizations on the dispatcher plugin The read shipped with its route-ledger row and its domain handler but no mountPackagesRoute() call, so the live router answered nothing for it and the Dogfood route-ledger live-mount parity gate went red on main. Co-Authored-By: Claude --- .../28622-package-customizations-mount.md | 10 ++ ...lugin.package-customizations-mount.test.ts | 108 ++++++++++++++++++ packages/runtime/src/dispatcher-plugin.ts | 3 + 3 files changed, 121 insertions(+) create mode 100644 .changeset/28622-package-customizations-mount.md create mode 100644 packages/runtime/src/dispatcher-plugin.package-customizations-mount.test.ts diff --git a/.changeset/28622-package-customizations-mount.md b/.changeset/28622-package-customizations-mount.md new file mode 100644 index 00000000000..0e30912b1b9 --- /dev/null +++ b/.changeset/28622-package-customizations-mount.md @@ -0,0 +1,10 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): `GET /api/v1/packages/:id/customizations` answers over HTTP + +Clause-②: no +Metadata-protocol: none + +The read of what this deployment changed on an installed base (its `.custom` pair's overrides, the members the pair adds to the base's objects, and the base's switched-off items) shipped with its route-ledger row and its domain handler, but the dispatcher plugin never mounted it. So on a host without `@objectstack/hono`'s catch-all, every request to it answered the transport's 404. The dispatcher plugin now mounts it beside the other `/packages/:id/...` reads, at the unscoped prefix and at `/environments/:environmentId/packages/:id/customizations`. diff --git a/packages/runtime/src/dispatcher-plugin.package-customizations-mount.test.ts b/packages/runtime/src/dispatcher-plugin.package-customizations-mount.test.ts new file mode 100644 index 00000000000..670c333a0a8 --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.package-customizations-mount.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `GET /api/v1/packages/:id/customizations` is MOUNTED: a request through the + * dispatcher plugin's registered route reaches the packages domain and answers + * 200 with the read's declared shape. + * + * The read shipped with its ledger row and its domain branch but no + * `mountPackagesRoute(...)` call, so the live router answered nothing for it, + * while `domains/packages-customizations.test.ts` stayed green: that suite + * calls `handlePackages()` directly, below the mount. This file drives the + * other half: the route `createDispatcherPlugin().start()` registers on the + * HTTP server, called the way the transport calls it, then `dispatch()`, then + * the domain. Without the mount there is no handler to call. + * + * Harness: the recording `IHttpServer` of `analytics-empty-cube-refusal.test.ts`. + * The read's own behaviour (the three lists, their sources, the refusals) is + * pinned in `domains/packages-customizations.test.ts`; here only the door + * matters, so the services are minimal stand-ins and identity is stubbed to a + * caller holding the read capability (as `domain-handler-registry.test.ts` + * does for the other `/packages` routes). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GetPackageCustomizationsResponseSchema } from '@objectstack/spec/api'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { HttpDispatcher } from './http-dispatcher.js'; + +const BASE = 'com.example.showcase'; +const OVERRIDE = { type: 'view', name: 'showcase_task.default', basedOn: '1.0.0', version: 2 }; + +function makeFakeServer() { + const handlers: Record any> = {}; + const rec = (verb: string) => (path: string, handler: any) => { + handlers[`${verb} ${path}`] = handler; + }; + return { + handlers, + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, + }; +} + +function makeCtx(fakeServer: any) { + const objectql = { + registry: { + getPackage: (id: string) => (id === BASE ? { id: BASE, manifest: { id: BASE, version: '1.0.0' } } : undefined), + getAllObjects: () => [], + getObject: () => null, + }, + find: async () => [], + }; + const protocol = { listServedOverrides: async ({ packageId }: { packageId: string }) => (packageId === BASE ? [OVERRIDE] : []) }; + const service = (name: string) => (name === 'objectql' ? objectql : name === 'protocol' ? protocol : undefined); + const kernel = { getService: service, getServiceAsync: async (name: string) => service(name) }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +function makeRes() { + const res: any = { + statusCode: undefined as number | undefined, + body: undefined as any, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: any) { res.body = b; return res; }, + }; + return res; +} + +beforeEach(() => { + vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext').mockResolvedValue({ + userId: 'u_reader', isSystem: false, systemPermissions: ['studio.access'], + }); +}); +afterEach(() => { vi.restoreAllMocks(); }); + +describe('GET /api/v1/packages/:id/customizations — mounted', () => { + it('the dispatcher plugin registers the route, and a request through it answers 200 with the declared shape', async () => { + const { server, handlers } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server)); + + const handler = handlers['GET /api/v1/packages/:id/customizations']; + expect(handler, 'GET /api/v1/packages/:id/customizations must be mounted').toBeTypeOf('function'); + + const res = makeRes(); + await handler({ params: { id: BASE }, query: {}, headers: {} }, res); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual({ + packageId: BASE, + overrides: [OVERRIDE], + extensions: [], + switchedOff: [], + counts: { overrides: 1, extensions: 0, switchedOff: 0 }, + }); + const parsed = GetPackageCustomizationsResponseSchema.safeParse(res.body.data); + expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 9ea7d79ce67..8c421f8946a 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1587,6 +1587,9 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu mountPackagesRoute('get', '', () => ''); mountPackagesRoute('post', '', () => ''); mountPackagesRoute('get', '/:id/export', (req) => `/${req.params.id}/export`); + // ADR-0146 D8 item 4 — what this deployment changed on an + // installed base (overrides, extensions, switched-off items). + mountPackagesRoute('get', '/:id/customizations', (req) => `/${req.params.id}/customizations`); mountPackagesRoute('get', '/:id', (req) => `/${req.params.id}`); mountPackagesRoute('delete', '/:id', (req) => `/${req.params.id}`); // Edit a package's manifest (name / description / version). `/:id`