fix(rest): give POST /packages/publish an owner on every boot (#7563) - #7666
Conversation
`POST /api/v1/packages/publish` answered 405 with
`Allow: DELETE, GET, HEAD, PATCH` on a live showcase boot. Not one of those
verbs belongs to the publish surface — POST is the only verb it has ever had.
They are `/packages/:id`'s method set, offered because with the publish route
unmounted that pattern was the only registration still matching the path, with
`id = "publish"`. The caller was told "this path exists, use another method",
and every method on offer would have operated on a package literally named
`publish`.
Two facts produced it, and both are repaired.
The REST package registrar was gated on `ctx.getService('package')` resolving
at the single instant `RestApiPlugin.start()` ran. `objectstack serve`
registers the capability providers (`requires: ['marketplace']` →
PackageServicePlugin) AFTER `createRestApiPlugin`, and start order follows
registration order for plugins with no dependency edge between them — so the
deployments that DO compose a package service are exactly the ones that
answered "no" at mount time. The service is now handed to the registrar as a
resolver and read per request, which makes the answer independent of
composition order instead of silently encoding it.
And `POST /packages/publish` has no dispatcher twin, so "not mounted" never
degraded to the 404 `direct-mount-composition.ts` documented — it degraded to a
sibling's 405. It therefore mounts unconditionally and answers its own honest
404, naming the surface rather than a package id, where no package service is
composed. The other three package routes deliberately do NOT follow: each
shadows a live dispatcher twin at a byte-identical pattern, so mounting them
without a service would replace three working routes with a degraded refusal.
The #7526 route-ledger <-> live-mount parity gate did not flag this: the row
was PINNED in UNEXERCISED_BY_THIS_BOOT, reasoned as "the registrar is
service-gated and this boot composes none". The reason was true and the
conclusion was wrong — an unmounted route is not automatically an unanswered
one. The pin is deleted (the route is observable for real now), and the pin
rule is tightened: a pinned path that any OTHER pattern matches now fails the
gate. That probe runs across every verb on purpose, because this class is
invisible to a method-scoped one — nothing registers POST on `/packages/:id`,
so `resolveMountedRoute('POST', ...)` reports a clean absence while the
adapter's `allowedMethodsForPath()` ignores the request method and builds the
405 from whatever else matches the path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wpp7qrGdj3e8vdX5LmkmM5
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7563
The defect
POST /api/v1/packages/publishanswered 405 withAllow: DELETE, GET, HEAD, PATCH. Not one of those verbs belongs to the publish surface —POSTis the only verb it has ever had. They are/packages/:id's method set, offered because with the publish route unmounted that pattern was the only registration still matching the path, withid = "publish". The caller was told "this path exists, use another method", and every method on offer would have operated on a package literally namedpublish.Which outcome, and why
The real publish handler answers wherever a
packageservice is composed; an honest 404 naming the surface everywhere else. Both, because the root cause turned out to be two separate things:1. The gate was a boot-instant snapshot, not a composition fact. The registrar was gated on
ctx.getService('package')resolving at the one momentRestApiPlugin.start()ran.objectstack serveregisters capability providers (requires: ['marketplace']→PackageServicePlugin,serve.ts:2414) aftercreateRestApiPlugin(serve.ts:2138), andresolvePluginOrderpreserves registration order for plugins with no edge between them. So on showcase — which declaresrequires: ['marketplace']— the service exists at request time and was absent at exactly the instant the mount decision was taken. The service is now handed to the registrar as a resolver and read per request.2. "Not mounted" never meant "404" for this path.
POST /packages/publishhas no dispatcher twin, so leaving it unowned did not degrade to the 404direct-mount-composition.tsdocumented — it degraded to a sibling's 405. It now mounts unconditionally and answers its own 404 naming the surface.404 rather than 503: a deployment that composed no marketplace capability will not grow one on retry, and 503 invites exactly that retry. It is also the answer the composition module has always documented for a skipped registrar.
The other three package routes deliberately do NOT follow.
GET /packages,GET /packages/:idandDELETE /packages/:ideach shadow a live dispatcher twin at a byte-identical pattern; mounting them without a service would replace three working routes with a degraded refusal. That asymmetry is pinned so a later "make it consistent" edit goes red.The parity gate question — did #7584 flag this? No, and the reason generalises
POST /api/v1/packages/publishwas pinned inUNEXERCISED_BY_THIS_BOOT, reasoned as "the registrar is service-gated and the showcase composes none". The reason was true and the conclusion was wrong: an unmounted route is not automatically an unanswered one. A conditional mount was excused from the very check that would have caught it — the "LEDGERED BUT NOT MOUNTED, and DISGUISED" failure the gate spells out for every unpinned row.The pin is deleted (the route is observable for real now) and the pin rule is tightened: a pinned path that any other pattern matches now fails the gate. That probe runs across every verb, which is the subtlety — this class is invisible to a method-scoped check. Nothing registers
POSTon/packages/:id, soresolveMountedRoute('POST', …)reports a clean absence, while the adapter'sallowedMethodsForPath()ignores the request method and builds the 405 from whatever else matches the path. A method-scoped version of this check was written first and failed to fail under mutation; that is what led to the all-verbs form.A second, unfixed measurement for the PM (not this card)
The same gate cannot see whether the three
:idpackage routes are mounted by REST or by the dispatcher — it compares patterns, and both owners register the same strings. So the REST registrar sitting out on a realobjectstack serveboot (cause 1 above) is invisible to it: the ledger says these three "shadow the dispatcher twin (registered first)", and on showcase they currently do not. Behaviour differs (the REST list route merges registry + database packages; the dispatcher's does not). Left alone here — changing which owner servesGET /packageson showcase is beyond this card's scope and is its own risk.Tests
New
packages/rest/src/package-publish-mount.test.ts(10 cases) + two live-router/live-wire pins in the parity gate. Every new test was mutation-proved:: [])SERVICE_UNAVAILABLE404s with a message about THIS surface…404s with a message about THIS surface…keeps its hands off the three dispatcher twins)runs the real handler when the service arrives AFTER the mount decisionrefuses an anonymous caller before disclosing whether the surface exists hereno pinned path is matched by any OTHER pattern, under any verbandPOST /packages/publish is owned by the publish route…Verification
pnpm lint✅ ·pnpm typecheck✅ (126/126)@objectstack/restfull suite ✅ 1415 passedroute-ledger-live-mount-parity.dogfood.test.ts(the new parity gate) ✅ 8 passedpnpm check:route-envelope✅ · client ledger-coverage / url-conformance / response-schema ✅Changeset:
.changeset/packages-publish-mount-or-404.md. Nocontent/docs/releases/**edits.rest-server.tsuntouched (#7566 owns/meta/appin this wave).🤖 Generated with Claude Code
https://claude.ai/code/session_01Wpp7qrGdj3e8vdX5LmkmM5
Generated by Claude Code