diff --git a/.changeset/reconcile-packages-post-and-ui-view-dialect.md b/.changeset/reconcile-packages-post-and-ui-view-dialect.md new file mode 100644 index 000000000..a5d20b033 --- /dev/null +++ b/.changeset/reconcile-packages-post-and-ui-view-dialect.md @@ -0,0 +1,30 @@ +--- +"@objectstack/rest": minor +"@objectstack/client": patch +--- + +fix(rest,client)!: reconcile the two REST↔client mismatches the #3587 audit +ledgered (#3610, #3611) + +**#3610 — `POST /api/v1/packages` publish-vs-install collision.** The REST +package registrar claimed the bare `POST /packages` for *marketplace publish* +(`{manifest, metadata}`), while the dispatcher packages domain gives the same +verb+path *install* semantics — and REST registers first in the production +stack (first-match-wins), so every `client.packages.install` call landed on +the publish handler and 400'd. Marketplace publish moves to +`POST /api/v1/packages/publish` (breaking for direct callers; a repo-wide and +objectui-wide sweep found zero). The dispatcher's `POST /packages/:id/publish` +(ADR-0033 draft publish) is two segments — different shape, no clash. The +dispatcher already writes both stores on install (`protocol.installPackage`) +and fully uninstalls on DELETE (`protocol.deletePackage`), so the remaining +REST GET/GET/DELETE shadows stay — they are compatible. + +**#3611 — UI view dialect split.** `meta.getView` spoke the `?type=` query +dialect that only the dispatcher `/ui` domain understands; the REST surface +mounts only the path form `/ui/view/:object/:type`, so the query form 404'd +wherever REST serves (e.g. project-scoped bases). The client now sends the +path form both surfaces accept; a URL-pinning test keeps it that way. + +REST route ledger updated: the two `mismatch` rows are resolved (packages +publish row is `server-only` publisher tooling; the ui row flips to `sdk`). +The ledger now carries zero mismatches. diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index d36e7d1be..a949b03c1 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -70,7 +70,16 @@ List installed packages. ### `POST /packages` -Publish a package (manifest + metadata) to the package registry. +Install a package from its manifest (SDK: `client.packages.install`). Re-installing an +already-installed `id` returns **409 Conflict** unless `overwrite: true`. + +**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, settings?: { ... }, enableOnInstall?: true, overwrite?: false }` +**Response**: `{ package: { id: "plugin-auth", version: "1.0.0", ... }, message?: "..." }` + +### `POST /packages/publish` + +Publish a package (manifest + metadata) to the package marketplace registry. This is +publisher tooling, not part of the app SDK surface. **Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, metadata: { objects: [...], views: [...], ... } }` **Response**: `{ success: true, message: "...", package: { id: "plugin-auth", version: "1.0.0" } }` diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 01f3ec67a..0841cc780 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -105,6 +105,20 @@ describe('ObjectStackClient', () => { expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta/object/customer', expect.any(Object)); expect(result.name).toBe('customer'); }); + + it('meta.getView speaks the path-param dialect both surfaces accept (#3611)', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { type: 'list' } }); + await client.meta.getView('customer'); + // NOT the ?type= query dialect — REST mounts only /ui/view/:object/:type, + // so the query form 404s wherever REST is the serving surface. + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/ui/view/customer/list', + ); + await client.meta.getView('customer', 'form'); + expect(String(fetchMock.mock.calls[1][0])).toBe( + 'http://localhost:3000/api/v1/ui/view/customer/form', + ); + }); }); describe('Approvals namespace (ADR-0019)', () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index df25b4e53..b44747e62 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -495,7 +495,14 @@ export class ObjectStackClient { getView: async (object: string, type: 'list' | 'form' = 'list') => { const route = this.getRoute('ui'); - const res = await this.fetch(`${this.baseUrl}${route}/view/${object}?type=${type}`); + // Path-param dialect (#3611): both surfaces accept it — the dispatcher + // /ui domain takes /view/:object/:type?, and the REST server mounts + // ONLY /ui/view/:object/:type. The old ?type= query dialect matched + // nothing on REST, so it 404'd wherever REST is the serving surface + // (e.g. project-scoped bases). + const res = await this.fetch( + `${this.baseUrl}${route}/view/${encodeURIComponent(object)}/${type}`, + ); return this.unwrapResponse(res); }, diff --git a/packages/rest/src/package-routes.ts b/packages/rest/src/package-routes.ts index 1ac4dcff2..4b8d38751 100644 --- a/packages/rest/src/package-routes.ts +++ b/packages/rest/src/package-routes.ts @@ -32,10 +32,18 @@ export interface PackageRoutesOptions { * * Provides endpoints for publishing, retrieving, and managing packages. * Routes: - * - POST /api/v1/packages - Publish a package + * - POST /api/v1/packages/publish - Publish a package to the marketplace registry * - GET /api/v1/packages - List all packages (merges registry + database) * - GET /api/v1/packages/:id - Get a specific package * - DELETE /api/v1/packages/:id - Delete a package + * + * Marketplace publish lives at `/packages/publish`, NOT at the bare + * `POST /packages` (#3610): that verb+path is the dispatcher packages + * domain's *install* route, and this registrar registers first in the + * production stack (first-match-wins), so claiming it here silently + * swallowed every `client.packages.install` call with a 400. The + * dispatcher's own `POST /packages/:id/publish` (ADR-0033 draft publish) + * is two segments — different shape, no clash. */ export function registerPackageRoutes( server: IHttpServer, @@ -45,8 +53,8 @@ export function registerPackageRoutes( ) { const packagesPath = `${basePath}/packages`; - // POST /api/v1/packages - Publish a package - server.post(packagesPath, async (req, res) => { + // POST /api/v1/packages/publish - Publish a package to the marketplace + server.post(`${packagesPath}/publish`, async (req, res) => { try { const { manifest, metadata } = req.body || {}; diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index cd5956db9..fc67c85ca 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -108,8 +108,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'compound-name save; same encoding barrier as the compound read' }, // ── ui ──────────────────────────────────────────────────────────────────── - { route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'mismatch', - note: 'path-param dialect; meta.getView sends /ui/view/:object?type= (query dialect), which this pattern cannot match — the dispatcher /ui domain answers it instead. Reconcile shapes or retire one dialect.' }, + { route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView', + note: 'was mismatch — meta.getView spoke the ?type= query dialect this pattern cannot match; since #3611 the client sends the path form both surfaces accept' }, // ── data CRUD ───────────────────────────────────────────────────────────── { route: 'GET /api/v1/data/:object', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.find' }, @@ -232,8 +232,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'POST /api/v1/data/:object/deleteMany', family: 'batch', source: 'route-manager', disposition: 'sdk', client: 'data.deleteMany' }, // ── packages (direct-mount registrar, service-gated) ────────────────────── - { route: 'POST /api/v1/packages', family: 'packages', source: 'direct-mount', disposition: 'mismatch', - note: 'REST handler is registry publish ({manifest, metadata}); the dispatcher /packages domain gives POST /packages install semantics with a different body. REST registers first, so packages.install traffic lands here — reconcile before relying on either.' }, + { route: 'POST /api/v1/packages/publish', family: 'packages', source: 'direct-mount', disposition: 'server-only', + note: 'marketplace registry publish ({manifest, metadata}) — publisher tooling, not app-SDK surface. Moved off the bare POST /packages in #3610: that verb+path is the dispatcher install route, and REST registering it first swallowed every packages.install call with a 400.' }, { route: 'GET /api/v1/packages', family: 'packages', source: 'direct-mount', disposition: 'sdk', client: 'packages.list', note: 'shadows the dispatcher twin (registered first); merges registry + database packages' }, { route: 'GET /api/v1/packages/:id', family: 'packages', source: 'direct-mount', disposition: 'sdk', client: 'packages.get',