Skip to content

Commit 16adb3c

Browse files
os-zhuangclaude
andauthored
fix(rest,client)!: reconcile the two REST↔client mismatches the audit ledgered (#3610, #3611) (#3621)
* fix(rest,client)!: reconcile the two REST-client mismatches the audit ledgered (#3610, #3611) #3610: 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 (zero direct callers found repo- and objectui-wide; the dispatcher's two-segment POST /packages/:id/publish is a different shape, no clash). The remaining REST GET/GET/DELETE shadows are compatible and stay: the dispatcher already double-writes on install (protocol.installPackage) and fully uninstalls on DELETE (protocol.deletePackage). #3611: meta.getView spoke the ?type= query dialect only the dispatcher /ui domain understands; REST 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, with a URL-pinning test — no test locked this URL before, which is exactly how the split survived. REST route ledger: both mismatch rows resolved (packages publish row is server-only publisher tooling; ui row flips to sdk). Zero mismatches remain. Closes #3610 Closes #3611 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 * docs(api): metadata-api packages section — bare POST installs, publish moved to /packages/publish The doc described the pre-#3610 collision state: bare POST /packages as marketplace publish. That path is install (409 duplicate guard, overwrite opt-in); publish now lives at POST /packages/publish. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1676de0 commit 16adb3c

6 files changed

Lines changed: 77 additions & 9 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/rest": minor
3+
"@objectstack/client": patch
4+
---
5+
6+
fix(rest,client)!: reconcile the two REST↔client mismatches the #3587 audit
7+
ledgered (#3610, #3611)
8+
9+
**#3610`POST /api/v1/packages` publish-vs-install collision.** The REST
10+
package registrar claimed the bare `POST /packages` for *marketplace publish*
11+
(`{manifest, metadata}`), while the dispatcher packages domain gives the same
12+
verb+path *install* semantics — and REST registers first in the production
13+
stack (first-match-wins), so every `client.packages.install` call landed on
14+
the publish handler and 400'd. Marketplace publish moves to
15+
`POST /api/v1/packages/publish` (breaking for direct callers; a repo-wide and
16+
objectui-wide sweep found zero). The dispatcher's `POST /packages/:id/publish`
17+
(ADR-0033 draft publish) is two segments — different shape, no clash. The
18+
dispatcher already writes both stores on install (`protocol.installPackage`)
19+
and fully uninstalls on DELETE (`protocol.deletePackage`), so the remaining
20+
REST GET/GET/DELETE shadows stay — they are compatible.
21+
22+
**#3611 — UI view dialect split.** `meta.getView` spoke the `?type=` query
23+
dialect that only the dispatcher `/ui` domain understands; the REST surface
24+
mounts only the path form `/ui/view/:object/:type`, so the query form 404'd
25+
wherever REST serves (e.g. project-scoped bases). The client now sends the
26+
path form both surfaces accept; a URL-pinning test keeps it that way.
27+
28+
REST route ledger updated: the two `mismatch` rows are resolved (packages
29+
publish row is `server-only` publisher tooling; the ui row flips to `sdk`).
30+
The ledger now carries zero mismatches.

content/docs/api/metadata-api.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,16 @@ List installed packages.
7070

7171
### `POST /packages`
7272

73-
Publish a package (manifest + metadata) to the package registry.
73+
Install a package from its manifest (SDK: `client.packages.install`). Re-installing an
74+
already-installed `id` returns **409 Conflict** unless `overwrite: true`.
75+
76+
**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, settings?: { ... }, enableOnInstall?: true, overwrite?: false }`
77+
**Response**: `{ package: { id: "plugin-auth", version: "1.0.0", ... }, message?: "..." }`
78+
79+
### `POST /packages/publish`
80+
81+
Publish a package (manifest + metadata) to the package marketplace registry. This is
82+
publisher tooling, not part of the app SDK surface.
7483

7584
**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, metadata: { objects: [...], views: [...], ... } }`
7685
**Response**: `{ success: true, message: "...", package: { id: "plugin-auth", version: "1.0.0" } }`

packages/client/src/client.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,20 @@ describe('ObjectStackClient', () => {
105105
expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta/object/customer', expect.any(Object));
106106
expect(result.name).toBe('customer');
107107
});
108+
109+
it('meta.getView speaks the path-param dialect both surfaces accept (#3611)', async () => {
110+
const { client, fetchMock } = createMockClient({ success: true, data: { type: 'list' } });
111+
await client.meta.getView('customer');
112+
// NOT the ?type= query dialect — REST mounts only /ui/view/:object/:type,
113+
// so the query form 404s wherever REST is the serving surface.
114+
expect(String(fetchMock.mock.calls[0][0])).toBe(
115+
'http://localhost:3000/api/v1/ui/view/customer/list',
116+
);
117+
await client.meta.getView('customer', 'form');
118+
expect(String(fetchMock.mock.calls[1][0])).toBe(
119+
'http://localhost:3000/api/v1/ui/view/customer/form',
120+
);
121+
});
108122
});
109123

110124
describe('Approvals namespace (ADR-0019)', () => {

packages/client/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,14 @@ export class ObjectStackClient {
495495

496496
getView: async (object: string, type: 'list' | 'form' = 'list') => {
497497
const route = this.getRoute('ui');
498-
const res = await this.fetch(`${this.baseUrl}${route}/view/${object}?type=${type}`);
498+
// Path-param dialect (#3611): both surfaces accept it — the dispatcher
499+
// /ui domain takes /view/:object/:type?, and the REST server mounts
500+
// ONLY /ui/view/:object/:type. The old ?type= query dialect matched
501+
// nothing on REST, so it 404'd wherever REST is the serving surface
502+
// (e.g. project-scoped bases).
503+
const res = await this.fetch(
504+
`${this.baseUrl}${route}/view/${encodeURIComponent(object)}/${type}`,
505+
);
499506
return this.unwrapResponse(res);
500507
},
501508

packages/rest/src/package-routes.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,18 @@ export interface PackageRoutesOptions {
3232
*
3333
* Provides endpoints for publishing, retrieving, and managing packages.
3434
* Routes:
35-
* - POST /api/v1/packages - Publish a package
35+
* - POST /api/v1/packages/publish - Publish a package to the marketplace registry
3636
* - GET /api/v1/packages - List all packages (merges registry + database)
3737
* - GET /api/v1/packages/:id - Get a specific package
3838
* - DELETE /api/v1/packages/:id - Delete a package
39+
*
40+
* Marketplace publish lives at `/packages/publish`, NOT at the bare
41+
* `POST /packages` (#3610): that verb+path is the dispatcher packages
42+
* domain's *install* route, and this registrar registers first in the
43+
* production stack (first-match-wins), so claiming it here silently
44+
* swallowed every `client.packages.install` call with a 400. The
45+
* dispatcher's own `POST /packages/:id/publish` (ADR-0033 draft publish)
46+
* is two segments — different shape, no clash.
3947
*/
4048
export function registerPackageRoutes(
4149
server: IHttpServer,
@@ -45,8 +53,8 @@ export function registerPackageRoutes(
4553
) {
4654
const packagesPath = `${basePath}/packages`;
4755

48-
// POST /api/v1/packages - Publish a package
49-
server.post(packagesPath, async (req, res) => {
56+
// POST /api/v1/packages/publish - Publish a package to the marketplace
57+
server.post(`${packagesPath}/publish`, async (req, res) => {
5058
try {
5159
const { manifest, metadata } = req.body || {};
5260

packages/rest/src/rest-route-ledger.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
108108
note: 'compound-name save; same encoding barrier as the compound read' },
109109

110110
// ── ui ────────────────────────────────────────────────────────────────────
111-
{ route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'mismatch',
112-
note: 'path-param dialect; meta.getView sends /ui/view/:object?type= (query dialect), which this pattern cannot matchthe dispatcher /ui domain answers it instead. Reconcile shapes or retire one dialect.' },
111+
{ route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView',
112+
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' },
113113

114114
// ── data CRUD ─────────────────────────────────────────────────────────────
115115
{ 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[] = [
232232
{ route: 'POST /api/v1/data/:object/deleteMany', family: 'batch', source: 'route-manager', disposition: 'sdk', client: 'data.deleteMany' },
233233

234234
// ── packages (direct-mount registrar, service-gated) ──────────────────────
235-
{ route: 'POST /api/v1/packages', family: 'packages', source: 'direct-mount', disposition: 'mismatch',
236-
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.' },
235+
{ route: 'POST /api/v1/packages/publish', family: 'packages', source: 'direct-mount', disposition: 'server-only',
236+
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.' },
237237
{ route: 'GET /api/v1/packages', family: 'packages', source: 'direct-mount', disposition: 'sdk', client: 'packages.list',
238238
note: 'shadows the dispatcher twin (registered first); merges registry + database packages' },
239239
{ route: 'GET /api/v1/packages/:id', family: 'packages', source: 'direct-mount', disposition: 'sdk', client: 'packages.get',

0 commit comments

Comments
 (0)