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
33 changes: 33 additions & 0 deletions .changeset/rest-package-routes-single-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/rest": minor
---

`GET /api/v1/packages`, `GET /api/v1/packages/:id` and `DELETE /api/v1/packages/:id` have one implementation: the runtime dispatcher's `/packages` domain. `@objectstack/rest`'s `registerPackageRoutes` no longer mounts its own copies of those three routes; it mounts `POST /api/v1/packages/publish` and nothing else.

The two copies had already diverged, and a comment in the REST registrar claimed its copies shadowed the dispatcher's while on a stock boot they were never mounted at all (the registrar decided at registration time, before the `package` service had registered). One URL, one body, ruled on #14503.

What changes on the wire, for a deployment whose composition really did reach the REST copies:

- `GET /packages/:id` answers `{ success: true, data: <row> }` — the installed-package row directly under `data`. FROM `data.package` TO `data`. There is no `{ package }` wrapper.
- The rows on `GET /packages` and the row on `GET /packages/:id` carry no `source: 'registry' | 'database' | 'both'` key. **Deliberately removed**, not ported: it had no reader outside the REST registrar's own tests — none in this repo's production code, the Console, the docs or the OpenAPI document, and the SDK declined to declare it twice on purpose.
- `GET /packages` and `GET /packages/:id` read the **installed** packages from the in-memory registry (`registry.getAllPackages()` / `registry.getPackage(id)`) and nothing else. The REST copies merged the durable `sys_packages` rows (`PackageService.list()` / `.get(id, version)`) into the registry set, so a package **published but not installed** was listed there and gettable there; on the surviving door it is neither. **Deliberately removed** with the routes, not silently dropped: the published-artifact store keeps its own surface (`POST /packages/publish` here, the marketplace browse elsewhere), and the family this door serves is the installed set.
- `?version=` is not read on `GET /packages/:id` or `DELETE /packages/:id`, so its repeated-parameter refusal (`400 VALIDATION_ERROR` on `?version=a&version=b`) is gone with it. **Deliberately removed**: the single implementation reads the installed package from the registry, and a version-scoped durable lookup was a behaviour only the REST copy had. The one in-tree sender is the SDK's `ScopedEnvironmentClient.packages.get(id, version?)`, whose binding is tracked on #12034.
- A missing package answers `404 RESOURCE_NOT_FOUND` with the message `Package '<id>' not found` (the dispatcher's spelling) instead of `Package "<id>" was not found.`.
- `DELETE /packages/:id` uninstalls the package (registry plus persisted metadata rows, `?keepData=true` to keep the object tables); the REST copy's version-scoped delete of a published artifact is gone.
- **The uninstall's tenancy width narrows.** The REST copy called `protocol.deletePackage({ packageId, allTenants: true })` — a package-wide uninstall across every tenant, the width #7705 case 4 pinned on purpose because that registrar had no organization to resolve. The surviving door calls `protocol.deletePackage({ packageId, organizationId?, keepData? })` with the organization it resolves for the caller (`resolveActiveOrganizationId`), so a `DELETE /packages/:id` that used to reach the REST copy now removes the package's metadata for the caller's active organization, not for all tenants. **Deliberately narrowed**, not silently dropped: one door, one width, and it is the width the dispatcher has always answered on every stock boot.
- **Capability refusals answer a different `error.code`.** On all three routes a caller holding neither `manage_metadata` (write) nor `studio.access` / `setup.access` (read) is refused with `403 PERMISSION_DENIED`. FROM `403 FORBIDDEN` TO `403 PERMISSION_DENIED`: the removed REST copies emitted `sendError(res, 403, 'FORBIDDEN', …)` explicitly, while the dispatcher's `requireManageMetadata` / `requireReadCapability` (`packages/runtime/src/domains/packages.ts`) call `deps.error(message, 403)` with no code and `packages/runtime/src/error-envelope.ts` derives one from the status — `standardErrorCodeForHttpStatus(403)` = `PERMISSION_DENIED`. **Same status, same message**: the two cohort messages ("Managing packages requires the `manage_metadata` capability." and "Reading packages requires the `studio.access` or `setup.access` capability.") are identical on both doors. Both codes are ADR-0112 standard members, so the envelope shape is unchanged; what moves is that a client branching on `err.code === 'FORBIDDEN'` for a package read or delete refusal stops matching on any composition that really did reach the REST copies.

`POST /api/v1/packages/publish` is unchanged.

Spec conformance on the surviving door is claimed for `GET /packages/:id` **only**: its `{ success, data: <row>, meta }` is exactly `GetInstalledPackageResponseSchema` (`packages/spec/src/api/package-api.zod.ts`, `data: InstalledPackageSchema` bare). The other two routes do **not** match their declarations, and the REST copies did not either — this drift is **pre-existing, not introduced by this release**, and is carded on #16781:

- `GET /packages` answers `{ packages, total }`, while `ListInstalledPackagesResponseSchema` requires `hasMore` (and declares `enabled` / `limit` / `cursor` inputs the door does not read).
- `DELETE /packages/:id` answers `{ success, registryRemoved, persisted }`, while `UninstallPackageApiResponseSchema` requires `packageId`.

Nothing in this release changes either shape; with one implementation there is now exactly one thing to reconcile, and #16781 carries that reconciliation together with the `responseSchema` pins the runtime ledger rows for `packages.list` / `packages.uninstall` still lack.

`GET /discovery` on the REST server now advertises `routes.packages` on every boot — the family base under which its publish route is mounted — instead of only when its own copy of the list route had been mounted at start. On a stock `objectstack serve` boot that copy never was (the `package` service registers after the REST plugin starts), so discovery omitted `routes.packages` while the dispatcher served the family; the SDK's convention fallback covered it.

The three removed REST rows are gone from `REST_ROUTE_LEDGER`; the runtime route ledger carries the surviving routes.

The environment-scoped mount (`/environments/:environmentId/packages…`) is served by the same dispatcher domain **only where the `@objectstack/hono` catch-all is mounted** (`createHonoApp`): the catch-all strips the environment prefix and hands the request to the domain. The dispatcher plugin's own explicit mounts (`plugin-hono-server`) register `/packages*` at the **unscoped** prefix only, and that plugin's sole route into the dispatcher (`setFallbackHandler`) serves declarative `apis:` endpoints, not domains. So a host composed as `plugin-hono-server` + the REST plugin with `enableProjectScoping: true` + the dispatcher plugin, **without** `createHonoApp`, had exactly one door for scoped package reads and deletes — the REST mirror this release removes — and after it has none: the scoped `GET /environments/:id/packages`, `GET /environments/:id/packages/:id` and `DELETE /environments/:id/packages/:id` answer the transport's plain 404 there. That composition is reachable from the open-core CLI when the standalone boot is skipped (`shouldBootWithLibrary()` false — any host config, or `OS_MODE=off`) and `api.enableProjectScoping` is forwarded verbatim. Every consumer population reachable from this repo is zero for the scoped mount (no in-repo production caller of `ScopedEnvironmentClient.packages.*`, no Console call to a scoped `/packages` URL); it is stated here so it is a known gap rather than a silent one. On a `plugin-hono-server` composition with `enableProjectScoping` and no `createHonoApp`, the scoped `/api/v1/environments/:id/packages[/:id]` routes have no door until #16781 lands (ruled C′ on #14503).
16 changes: 9 additions & 7 deletions content/docs/kernel/contracts/metadata-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -414,15 +414,17 @@ const draft = await metadataService.get('object', 'opportunity');

### REST Endpoints

The REST layer mounts package routes under `/api/v1/packages` and per-item metadata
routes under `/api/v1/meta`. Publishing a single metadata item's pending draft is done
via the `/meta/:type/:name/publish` route.
The package family under `/api/v1/packages` is served by the runtime dispatcher's
`/packages` domain — one implementation for the reads and the uninstall — with the
REST layer contributing only the marketplace publish route beside it; per-item
metadata routes live under `/api/v1/meta`. Publishing a single metadata item's
pending draft is done via the `/meta/:type/:name/publish` route.

| Method | Path | Description |
|:---|:---|:---|
| `POST` | `/api/v1/packages` | Publish a package (body: `{ manifest, metadata }`) |
| `GET` | `/api/v1/packages` | List all packages (registry + database) |
| `GET` | `/api/v1/packages/:id` | Get a specific package |
| `DELETE` | `/api/v1/packages/:id` | Delete a package |
| `POST` | `/api/v1/packages/publish` | Publish a package to the marketplace registry (body: `{ manifest, metadata }`) — the REST registrar's one route |
| `GET` | `/api/v1/packages` | List the installed packages (the in-memory registry; published-but-not-installed artifacts are not listed) |
| `GET` | `/api/v1/packages/:id` | Get an installed package — the bare row under `data`; a missing id answers `404 RESOURCE_NOT_FOUND`, message `Package 'ID' not found` |
| `DELETE` | `/api/v1/packages/:id` | Uninstall a package for the caller's organization (`?keepData=true` keeps the object tables) |
| `POST` | `/api/v1/meta/:type/:name/publish` | Promote a metadata item's pending draft to live |
| `POST` | `/api/v1/meta/:type/:name/rollback` | Restore a historical version as the live overlay |
4 changes: 3 additions & 1 deletion content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,9 @@ is what makes package uninstall well-defined — and enforced: uninstalling a
package (`DELETE /api/v1/packages/:id`) revokes its own sets, their
position/user bindings, and its pending audience-binding suggestions in the
same request (no ghost grants); the uninstall response reports the revocation
under `cleanups`. Environment-authored sets and other packages' rows survive.
under `data.persisted.cleanups` (the dispatcher's `/packages` domain answers
the route, and it nests the protocol's uninstall report under `persisted`).
Environment-authored sets and other packages' rows survive.

## One authoritative store — the record is a projection (ADR-0094)

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ The largest single consumer — **17 of the 106 sites**.
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` |
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `packages/rest/src/package-routes.ts#refusePackageRequest` |
| 54 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` |
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` |
| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#refuseUnrelatedScreenRead` |
Expand Down
17 changes: 12 additions & 5 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
// conclusion that gets re-derived from scratch otherwise:
//
// WHAT THE LEDGERS DO COVER — richly, and more than this table ever has.
// `packages/rest/src/rest-route-ledger.ts`: 94 audited rows over 19 families,
// `packages/rest/src/rest-route-ledger.ts`: 91 audited rows over 19 families,
// every route `@objectstack/rest` mounts, enumerated through
// `RestServer.getRoutes()` on a booted server and guarded per route by
// `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars;
Expand Down Expand Up @@ -277,18 +277,25 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
kinds: ['ROUTE_ENUMERATION'],
probes: 1,
keys: 19,
population: 94,
reachable: 94,
population: 91,
reachable: 91,
blindSpot: 0,
populationRule: 'ledger rows inside REST_ROUTE_LEDGER; reachable = rows carrying a `family` (each distinct value mints a key)',
controls: { "route: '": 94, "family: '": 94, RestRouteLedgerEntry: 2 },
controls: { "route: '": 91, "family: '": 91, RestRouteLedgerEntry: 2 },
note:
'The audited disposition of every route @objectstack/rest mounts, enumerated through ' +
'RestServer.getRoutes() on a booted server and guarded per route by rest-route-ledger.conformance.test.ts. ' +
'That guard is why this file can be a population source and a regex table cannot: a mounted route with no ' +
'row here is already RED in another package, so a new family cannot be silently absent from this file, ' +
'and therefore cannot be silently absent from the authz ratchet either. 19 families; 1 classified by a ' +
'matrix row (metadata), 18 enumerated in the shrink-only baseline.',
'matrix row (metadata), 18 enumerated in the shrink-only baseline. Re-measured 94 -> 91 when the ' +
'three REST package read/delete rows (GET /packages, GET /packages/:id, DELETE /packages/:id) left the ' +
'ledger with their routes; each carried `family: packages`, so `reachable` moved with ' +
'`population` (91/91) and the blind spot stays 0 -- the family itself survives on the publish row.',
// The 94 -> 91 re-measurement above landed with #14503 (the REST registrar
// keeps only POST /packages/publish; the dispatcher domain is the single
// implementation of the reads and the delete). The id lives here, not in
// the string: a runtime string reaches readers who cannot resolve it.
},
{
file: 'packages/runtime/src/route-ledger.ts',
Expand Down
Loading
Loading