Skip to content

Commit c076e62

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16711-subclass-shadowed-declarations
2 parents a15e006 + 094b8fd commit c076e62

53 files changed

Lines changed: 2400 additions & 4114 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
fix(driver-turso)!: `timeout` beside a pre-configured `client` in remote mode is refused at construction instead of being accepted and never delivered (ADR-0049 enforce-or-remove)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed at the driver constructor: no key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `TursoDriverConfig.timeout` and `TursoDriverConfig.client` keep their names and types, and the published `turso` config schema is untouched (it never declared `client`, which is a live object rather than authorable metadata). What moves is which CONFIGURATIONS `new TursoDriver()` accepts, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The refusal itself names both keys, the mode and both ways out, and which of the two an author wants is authoring intent no ledger line can decide. -->
8+
9+
`TursoDriverConfig.timeout` bounds remote operations over HTTP by installing a `fetch` that aborts at the window — and it installs it in exactly one place, while the driver is CREATING its `@libsql/client`. A pre-configured `TursoDriverConfig.client` arrives with its transport already built, and both remote sites that consume it (`connect()` and the lazy connect factory the transport self-heals through) skip the builder entirely. So on that one composition the window reached nothing: the driver constructed, connected, and ran every request unbounded, while `timeout`'s contract promised "every request the client's HTTP transport makes" and `client`'s said nothing about the key ceasing to apply.
10+
11+
**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside a supplied `client` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and a message that names both keys, the window, the mode and both ways out:
12+
13+
```
14+
`TursoDriverConfig.timeout` (30000 ms) is set beside `TursoDriverConfig.client` in
15+
remote mode, and on that pair it bounds nothing: the window is the `fetch` this
16+
driver hands @libsql/client while CREATING the remote client, and a pre-configured
17+
client is already built — its transport is not the driver's to replace … Either drop
18+
`client` and let the driver create the remote client, where every request IS bounded
19+
and a stalled endpoint fails as TIMEOUT / 504, or keep `client` and omit `timeout`,
20+
building the bound into that client yourself when you call `createClient({ fetch })`.
21+
Replica mode is unaffected: there `sync()` is bounded whatever client is in use.
22+
```
23+
24+
**Who can reach this, measured on this tree.** The datasource seam cannot: `buildTursoDriverConfig` emits nine keys (`url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, `sync`, `timeout`, `mode`, `schemaMode`) and `client` is not among them — it is a live object, not authorable metadata, and the published `turso` schema documents its absence deliberately. So no datasource, environment variable or `sys_metadata` row can produce this pair; only code calling `new TursoDriver(...)` / `createTursoDriver(...)` directly. Across the 138 construction sites in this repository, the only one pairing the two keys outside the new pin file is a replica-arm test fixture, which stays accepted. Whether any out-of-repo host composes them is NOT measured and is not claimed to be zero.
25+
26+
**What stays accepted — the refusal is no wider than the gap**, pinned by controls:
27+
28+
- a supplied `client` with no `timeout`, and an explicit `client: undefined`, which the `??` at both sites treats as absent;
29+
- `timeout` with no `client` — the client the driver builds IS bounded;
30+
- `timeout: 0` beside a client, the documented "no bound", which asks for nothing;
31+
- the whole REPLICA arm, where `sync()` is bounded by the driver around the awaited promise whatever client is in use, so the key is not inert there and the pair is still accepted.
32+
33+
**What is deliberately NOT done**: wrapping or re-creating the caller's client so the window rides after all. A client handed in for custom caching, connection pooling or testing is the caller's object, and replacing its transport because `timeout` is set would discard the configuration it was built to carry, behind the author's back — the same reason a `wss://` url is not silently re-routed over HTTP.
34+
35+
**What an affected author does.** The refusal text says which two: drop `client` and let the driver create the remote client, which bounds every request; or keep `client` and drop `timeout`, building the bound into that client where it is created, since `@libsql/client` reads its `fetch` at creation. Which of the two is wanted is authoring intent, and the choice is made in place at the driver config.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
`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.
6+
7+
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.
8+
9+
What changes on the wire, for a deployment whose composition really did reach the REST copies:
10+
11+
- `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.
12+
- 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.
13+
- `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.
14+
- `?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.
15+
- 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.`.
16+
- `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.
17+
- **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.
18+
- **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.
19+
20+
`POST /api/v1/packages/publish` is unchanged.
21+
22+
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:
23+
24+
- `GET /packages` answers `{ packages, total }`, while `ListInstalledPackagesResponseSchema` requires `hasMore` (and declares `enabled` / `limit` / `cursor` inputs the door does not read).
25+
- `DELETE /packages/:id` answers `{ success, registryRemoved, persisted }`, while `UninstallPackageApiResponseSchema` requires `packageId`.
26+
27+
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.
28+
29+
`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.
30+
31+
The three removed REST rows are gone from `REST_ROUTE_LEDGER`; the runtime route ledger carries the surviving routes.
32+
33+
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).
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`RestServerConfig` now documents its own reachability: the `crud` / `metadata` / `batch` blocks are embedder-only, and the schema says so instead of implying a deployment posture nobody can author.
6+
7+
`RestServerConfig` is the argument a host passes when it constructs the REST server, and there is exactly one door: `createRestApiPlugin({ api })`, whose `start()` is the only non-test site that reaches `new RestServer(...)`. No shipped boot path opens it with a config of its own — `os serve` reads the stack config's own top-level `api:` block and forwards exactly two keys out of it (`api.enableProjectScoping`, `api.projectResolution`), and the dev plugin passes none at all. So on a CLI-started deployment every other key is whatever its `.default()` says, and until now the schema did not say so anywhere an operator would look.
8+
9+
- **The file header gains a `WHO CAN WRITE THIS CONFIG` section**, which is the part that reaches the generated reference page, and the `crud` / `metadata` / `batch` sub-schemas each gain a `Reachability: EMBEDDER-ONLY` line. The three keys' entries on the parent `RestServerConfig` table say it too, so the fact survives into `content/docs/references/api/rest-server.mdx` rather than living only in the TS source.
10+
- **One documented carve-out, and it is the security-relevant key.** `RestServer.normalizeConfig` folds the environment into the *effective* value of `metadata.maskObjectFields`: `OS_ALLOW_UNMASKED_OBJECT_METADATA` turns the ADR-0106 D8 mask off whatever the key says. That env var is the only thing outside an embedder's argument that moves any value in this schema, and it is now stated on the key, on the sub-schema and in the file header.
11+
- **`metadata.maskObjectFields`'s docblock is corrected.** It said `false` "opts this server out and serves the full schema to every authenticated caller" and offered the env var as a "deployment-wide counterpart", as if a deployment could pick either. Only an embedder can write the key; a deployment reaches the mask only through the env var.
12+
- **`api.enableSearch` is corrected the same way.** Its docblock called it a "Deployment-wide switch" and its `describe()` a "deployment-wide search opt-out"; `os serve` does not forward it either, so it is embedder-only like the rest of the block apart from the two project-scoping keys.
13+
- **The liveness ledger answers the ADR-0049 question in writing.** Every `live` row in `liveness/crud_endpoints.json`, `metadata_endpoints.json` and `batch_endpoints.json` gains a `REACHABILITY` sentence, and each file's `_note` carries the measurement once. `status` and `verifiedAt` are untouched on purpose: `live` answers who *reads* a key, reachability answers who can *set* it, and adding the second re-verified no call graph.
14+
15+
⚠️ **A correction to the record this change is built on.** An earlier draft of these sentences named a second door, `createHonoServerPlugin({ restConfig })`. No such function exists — a definition probe returns zero across the tree, against a positive control that finds `createRestApiPlugin` at `packages/rest/src/rest-api-plugin.ts:115`. `HonoServerPlugin` is a class that declares a `restConfig?: RestServerConfig` option whose single reader takes `api.basePath` for the SPA fallback; it never constructs a REST server, so it is not a door onto any of these keys. The claim was inherited from prose that was already in the tree, and on a card whose whole subject is a declared posture nobody can reach, publishing a declared door that does not exist would have been the same defect one level up. Every place this change touches now says the corrected thing.
16+
17+
⚠️ **`batch.maxBatchSize` really does describe itself as deployment policy — in another package.** The phrase does not occur in `packages/spec/src/api/rest-server.zod.ts`, but it exists verbatim in the REST server: *"The cap is deployment policy — `RestServerConfig.batch.maxBatchSize` (1..1000, default 200)"* at `packages/rest/src/rest-server.ts:2071`. Same defect class, different package, and not touched here — it is owed to a follow-up in `packages/rest`.
18+
19+
No behaviour changes and no schema shape changes — no key, default, bound or refusal moves, so the accept set is byte-identical. This is prose plus ledger rows, and the regenerated `content/docs/references/api/rest-server.mdx` that follows from the `describe()` edits.

content/docs/kernel/contracts/metadata-service.mdx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -414,15 +414,17 @@ const draft = await metadataService.get('object', 'opportunity');
414414

415415
### REST Endpoints
416416

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

421423
| Method | Path | Description |
422424
|:---|:---|:---|
423-
| `POST` | `/api/v1/packages` | Publish a package (body: `{ manifest, metadata }`) |
424-
| `GET` | `/api/v1/packages` | List all packages (registry + database) |
425-
| `GET` | `/api/v1/packages/:id` | Get a specific package |
426-
| `DELETE` | `/api/v1/packages/:id` | Delete a package |
425+
| `POST` | `/api/v1/packages/publish` | Publish a package to the marketplace registry (body: `{ manifest, metadata }`) — the REST registrar's one route |
426+
| `GET` | `/api/v1/packages` | List the installed packages (the in-memory registry; published-but-not-installed artifacts are not listed) |
427+
| `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` |
428+
| `DELETE` | `/api/v1/packages/:id` | Uninstall a package for the caller's organization (`?keepData=true` keeps the object tables) |
427429
| `POST` | `/api/v1/meta/:type/:name/publish` | Promote a metadata item's pending draft to live |
428430
| `POST` | `/api/v1/meta/:type/:name/rollback` | Restore a historical version as the live overlay |

content/docs/permissions/permission-sets.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,9 @@ is what makes package uninstall well-defined — and enforced: uninstalling a
293293
package (`DELETE /api/v1/packages/:id`) revokes its own sets, their
294294
position/user bindings, and its pending audience-binding suggestions in the
295295
same request (no ghost grants); the uninstall response reports the revocation
296-
under `cleanups`. Environment-authored sets and other packages' rows survive.
296+
under `data.persisted.cleanups` (the dispatcher's `/packages` domain answers
297+
the route, and it nests the protocol's uninstall report under `persisted`).
298+
Environment-authored sets and other packages' rows survive.
297299

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

0 commit comments

Comments
 (0)