Skip to content

Commit 637bb32

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/envelope-drift-route-modules-v2zoky
2 parents 2bb7c4d + 857a6cf commit 637bb32

47 files changed

Lines changed: 1905 additions & 409 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Both discovery builders now compute the `metadata` service entry from the implementation that fills the slot, instead of hardcoding opposite verdicts for it (#4089).
7+
8+
`metadata` sat in a "kernel-provided (always available)" block above the loop that reads `__serviceInfo`, hardcoded separately in each builder — and the two disagreed about the same slot:
9+
10+
- `@objectstack/runtime`'s dispatcher declared it permanently `status: 'degraded'` with `message: 'In-memory registry; DB persistence pending'`, so a stack with `MetadataPlugin` and a real `sys_metadata` table was still reported as having no persistence.
11+
- `@objectstack/metadata-protocol` declared the same slot permanently `status: 'available'`, so the kernel's in-memory fallback (`createMemoryMetadata`, auto-registered when no metadata plugin is present) read exactly like a persisted registry — the `__serviceInfo` marker #4058 gave it went unread here.
12+
13+
Both now read the registered service's `__serviceInfo` (via `readServiceSelfInfo`) and report what it declares:
14+
15+
- kernel in-memory fallback, or plugin-dev's dev registry → `status: 'degraded'` plus that implementation's own `message`, which names what is missing and what to install.
16+
- `MetadataPlugin` (or any implementation carrying no marker) → `status: 'available'` with no message.
17+
18+
`handlerReady: true` is now stated unconditionally on both sides: it answers "is `/api/v1/meta` mounted?", and that route is served by the protocol whichever implementation occupies the slot — a degraded service in it does not unmount the route. Nothing about routing, gating, or dispatch changes; consumers that treat `status` as a capability claim (AI agents, the console) simply stop being told two different things by two hosts.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/hono": minor
4+
"@objectstack/plugin-dev": patch
5+
---
6+
7+
fix(runtime,hono,plugin-dev): retire the dispatcher's `/storage` bridge — it never spoke the storage contract (#4087)
8+
9+
`POST /api/v1/storage/upload` and `GET /api/v1/storage/file/:id` were a
10+
dispatcher-side bridge to the `file-storage` service slot, written against a
11+
service shape that does not exist:
12+
13+
- **Upload** called the contract's `upload(key, data, options?)` as
14+
`upload(file, { request })` — the parsed file object landed in the `key`
15+
slot and `{ request }` in `data`. That is a `TypeError` against every
16+
implementation in the repo (`S3StorageAdapter`, `LocalStorageAdapter`,
17+
`SwappableStorageService`, plugin-dev's in-memory one), not a
18+
near-miss: `Buffer.from({}) → ERR_INVALID_ARG_TYPE`, or an object used as
19+
an S3 object key / `path.join` segment.
20+
- **Download** branched on `result.url` / `result.redirect` / `result.stream`
21+
/ `result.mimeType` while the contract's `download(key)` resolves a
22+
`Buffer`, so every branch fell through and the route answered a
23+
JSON-serialized Buffer.
24+
25+
Both routes are removed, along with `HttpDispatcher.handleStorage()`, the
26+
`/storage` domain registration, the dispatcher-plugin mounts and the two route
27+
ledger rows.
28+
29+
**Migration.** There is nothing to migrate off in practice — neither route
30+
could complete a request. (They were reachable: `service-storage` mounts
31+
`/storage/upload/presigned`, not `/storage/upload`, so nothing shadowed them.
32+
They simply had no caller — no SDK method builds those URLs.)
33+
`/api/v1/storage` is `@objectstack/service-storage`'s surface and always was
34+
the working one:
35+
36+
- Upload — FROM `POST /api/v1/storage/upload` TO the presigned protocol
37+
(`POST /storage/upload/presigned` → direct `PUT` to the returned URL →
38+
`POST /storage/upload/complete`), or `client.storage.upload(file)`, which
39+
runs all three steps.
40+
- Download — FROM `GET /api/v1/storage/file/:id` TO
41+
`GET /storage/files/:fileId/url` (`client.storage.getDownloadUrl(fileId)`)
42+
for a signed URL, or `GET /storage/files/:fileId` for a stable browser URL
43+
that 302s to it.
44+
45+
Install `@objectstack/service-storage` to get those routes; without it
46+
`/api/v1/storage` now has no handler, which is the same answer every other
47+
uninstalled capability gives.
48+
49+
Two follow-on corrections keep `declared === enforced`:
50+
51+
- `@objectstack/hono` no longer mounts `app.all('<prefix>/storage/*')`. That
52+
wildcard claimed the whole `/storage` subtree for the two dead routes, so
53+
every other path under it — service-storage's protocol above all — got the
54+
bridge's own 404 rather than falling through. Storage is ordinary catch-all
55+
traffic now.
56+
- Discovery keeps gating `routes.storage` on `isServiceServeable` — the shared
57+
`handlerReady` predicate #4058 step 2 introduced — and plugin-dev's in-memory
58+
implementation now self-declares `handlerReady: false`. #4058 deliberately
59+
left that one serving because the `/storage` bridge was still there to serve
60+
it; with the bridge retired nothing routes HTTP to that slot, so `false` is
61+
the honest value — the position `realtime` has held since ADR-0076 D12. The
62+
implementation keeps working for in-process callers; it is simply no longer
63+
advertised as a reachable HTTP capability.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/core': patch
4+
'@objectstack/metadata': patch
5+
'@objectstack/runtime': patch
6+
---
7+
8+
fix(cli,core,metadata,runtime): `os serve` boots with no compiled artifact — the platform does not need an application to start (#4085)
9+
10+
The artifact (`dist/objectstack.json`) defines an **application**. ObjectStack is
11+
a development platform, so it has to start without one — but `os serve
12+
objectstack.config.ts` died during boot whenever the artifact was absent:
13+
14+
```
15+
Loading objectstack.config.ts...
16+
[StandaloneStack] artifact read FAILED: path='…/dist/objectstack.json' error=ENOENT…
17+
18+
✗ Service 'manifest' is async - use await
19+
```
20+
21+
Exit 1 — on a **known-good app** (`examples/app-todo` fails the same way with
22+
only its `dist/objectstack.json` moved aside), and on every freshly authored
23+
project between `os init` and its first `os compile`. The message named neither
24+
the missing artifact nor a fix, so it read as an internal kernel fault.
25+
26+
Three separate faults, each of which alone was enough to refuse the boot:
27+
28+
- **`serve` registered the config-derived `AppPlugin` before the stack's own
29+
`plugins[]`.** Registration order *is* the kernel's init/start order, and that
30+
slot sits ahead of `ObjectQLPlugin` (which registers `manifest`/`objectql`) and
31+
`DefaultDatasourcePlugin` (which connects the database the app seeds through).
32+
The wrap is now **appended** to `plugins[]`, the same slot
33+
`createStandaloneStack` gives its artifact-derived `AppPlugin` — so config-boot
34+
and artifact-boot share one plugin order. The artifact path never hit this,
35+
which is exactly what made a plugin-**order** bug look artifact-related.
36+
37+
- **`ctx.getService()` reported a never-registered service as "is async".**
38+
`PluginLoader.getService` is an `async` method, so its return value is *always*
39+
a Promise and its internal "not found" rejection can never surface
40+
synchronously — the kernel read the answer off that Promise and told every
41+
caller to `await` a service that did not exist, while the `not found` branch
42+
below it was unreachable. It now decides from the registry: absent ⇒
43+
`[Kernel] Service 'x' not found`, registered-but-uninstantiated ⇒ the unchanged
44+
`Service 'x' is async - use await`. The same crash now reads
45+
`[Kernel] Service 'manifest' not found`, which points at the layer that is
46+
actually wrong.
47+
48+
- **`MetadataPlugin` treated an absent `local-file` artifact as fatal.**
49+
`createStandaloneStack` always points it at `dist/objectstack.json`, so a stack
50+
with no app at all could not boot. A **missing** local artifact is now "nothing
51+
compiled yet": it logs, starts empty, and leaves the artifact watcher armed, so
52+
a later `os compile` hydrates the running server. The tolerance is
53+
ENOENT-only — a malformed or unreadable artifact stays fatal — and
54+
`bootstrap: 'artifact-only'` (sealed runtime, where the artifact *is* the
55+
deployment) keeps failing loudly rather than silently serving an empty runtime.
56+
57+
`[StandaloneStack] artifact read FAILED … ENOENT` is likewise no longer shouted
58+
at callers for whom "no artifact" is a healthy state; a present-but-unusable
59+
artifact keeps the loud warning.
60+
61+
Pinned by an e2e pair that drives the real `os serve` with **no `os compile`
62+
anywhere**: an app defined only by `objectstack.config.ts` (asserting its object
63+
is in the started plugin set, not merely that boot survived) and a bare
64+
`export default {}` platform. The #4012 fixture drops the `os compile` this bug
65+
had forced on it.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: reject unknown keys on the approval authoring schemas (#4001 step 3)
6+
7+
Third click of the unknown-key strictness ratchet (flow + permission in
8+
#4071, RLS / sharing / position in #4099). Approval is a v17-new authoring
9+
surface — tightened while young, before stored volume exists:
10+
11+
- **`automation/approval.zod.ts`**`ApprovalNodeConfigSchema`,
12+
`ApprovalNodeApproverSchema`, `ApprovalEscalationSchema`, and
13+
`DecisionOutputDefSchema` are `.strict()` with fixable errors. An approval
14+
gate that quietly ignores half its config is the worst instance of the
15+
ADR-0078 trap — the request routes, but not the way the author declared.
16+
- The published JSON schema (`getApprovalNodeConfigJsonSchema`) now carries
17+
`additionalProperties: false` into the Studio property form AND
18+
`registerFlow()`'s per-node config validation (#4027/#4040), so an unknown
19+
key inside an approval node's `config` is rejected at registration too.
20+
21+
**Migration.** Any key now rejected was previously stripped and had no
22+
runtime effect — removing or renaming it never changes behavior. Mappings
23+
baked into the errors include the ADR-0019 re-home map for process-era
24+
concepts: `steps` → successive approval NODES on the canvas, `entryCriteria`
25+
→ the condition on the entering edge, `onApprove` / `onReject` → the nodes
26+
wired to the `approve` / `reject` out-edges, `rejectionBehavior` → a declared
27+
back-edge (ADR-0044) with `maxRevisions`. Plus spelling aliases:
28+
`mode` / `approvalMode``behavior`, `quorum``minApprovals`,
29+
`statusField``approvalStatusField`, `org``organization`,
30+
`expandAs``resolveAs`, `timeout` / `hours` / `sla``timeoutHours`,
31+
`to` / `target``escalateTo`, `name``key`, `widget``type`.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
---
3+
4+
chore(tooling): enumerate namespace-claiming wildcard mounts and require each to be classified (#4116)
5+
6+
Release-nothing: adds `scripts/check-wildcard-fallthrough.mjs` plus its CI step.
7+
No package code changes.
8+
9+
A handler mounted on `<prefix>/*` claims an entire namespace, and Hono runs
10+
handlers matching a path in registration order with the first Response winning.
11+
So a TERMINAL wildcard — one that always answers and never yields — makes every
12+
other route under that prefix reachable only when it happens to register first.
13+
That shape has cost four fixes (#2567, #4018, #4088/#4092, cloud#923) and every
14+
one was found by hand, after the fact, by someone reading the code for an
15+
unrelated reason.
16+
17+
The two driven tests #4092 and cloud#923 shipped each pin one catch-all, and are
18+
structurally unable to cover the next wildcard someone mounts. What never existed
19+
is the enumeration. This scan is it: three states (`yields`, verified from the AST
20+
so the entry cannot rot; `exempt` with a reason; `ratchet` naming the issue), and
21+
a mount the scan finds but the ledger does not declare is an error, never a
22+
default — the same shape as `check-route-envelope.mjs` (#3843).
23+
24+
It found 13 namespace-claiming mounts where a manual grep had found 3, including
25+
two real, previously untracked instances of the #4088 defect in
26+
`packages/adapters/hono` (`${prefix}/auth/*` and `${prefix}/storage/*`), now
27+
ratcheted under #4117.

.github/workflows/lint.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ jobs:
123123
- name: Error-code casing guard
124124
run: pnpm check:error-code-casing
125125

126+
# Namespace-wildcard fall-through guard (#4116). A handler mounted on
127+
# `<prefix>/*` claims the whole namespace, and Hono's first-registered
128+
# handler that answers wins — so a TERMINAL wildcard makes every other
129+
# route under that prefix reachable only by registration luck. That shape
130+
# has now cost four fixes (#2567, #4018, #4088/#4092, cloud#923) and every
131+
# one was found by hand, never by CI. Per-plugin tests cannot cover the
132+
# next one; what was missing is the ENUMERATION. Three states — yields
133+
# (verified from the AST, so it cannot rot) / exempt-with-reason / ratchet
134+
# — and an undeclared mount is an error, never a default. Runs its own
135+
# --self-test first.
136+
- name: Wildcard fall-through guard
137+
run: pnpm check:wildcard-fallthrough
138+
126139
# Release-notes drift guard: the platform is one version-locked train, so
127140
# every released @objectstack/spec major must have a curated, navigable
128141
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

content/docs/api/client-sdk.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ async function main() {
4040

4141
// 2. Check available services
4242
console.log('Services:', discovery.services);
43-
// → { metadata: { enabled: true, status: 'degraded' }, data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } }
43+
// → { metadata: { enabled: true, status: 'degraded', message: 'In-memory metadata registry — …' },
44+
// data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } }
45+
// `metadata` reports the implementation actually behind it: `degraded` plus a
46+
// message naming what is missing while the kernel's in-memory fallback fills
47+
// the slot, `available` once MetadataPlugin provides a persisted registry.
4448

4549
// 3. Query data
4650
const tasks = await client.data.find('todo_task', {

content/docs/api/index.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ Returns the full discovery manifest.
132132

133133
Disabled/uninstalled route keys (e.g. `auth`, `analytics`, `workflow`) are omitted from `routes` entirely rather than set to `null`; check `services` to tell "not installed" apart from "installed but not yet mounted here." The sample above shows a minimal install: `analytics` reports `unavailable` and advertises no route until `@objectstack/service-analytics` registers the engine — the dispatcher then also mounts `/api/v1/analytics/*` (the routes are capability-conditional; an uninstalled capability answers 404 for every method).
134134

135+
`metadata` is reported from whatever implementation fills its slot, so the sample's `available` is the `MetadataPlugin` case (a persisted `sys_metadata` registry). A stack running the kernel's in-memory fallback instead reports `status: "degraded"` with a `message` naming what is missing and what to install. `handlerReady` is `true` either way: `/api/v1/meta` is served by the protocol, so the route is mounted whichever registry sits behind it.
136+
135137
### `GET /.well-known/objectstack`
136138

137139
Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`, `features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does not. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`.

content/docs/api/plugin-endpoints.mdx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,43 @@ protocol `ai.chatStream` parses and owns message state for you.
144144

145145
### File Storage (`/storage`) — Plugin Required
146146

147-
| Method | Endpoint | Description |
148-
|:-------|:---------|:------------|
149-
| POST | `/storage/upload` | Upload a file |
150-
| GET | `/storage/file/:id` | Download a file |
147+
Provided by `@objectstack/service-storage`, which registers these routes on the
148+
host HTTP server. Uploads are a three-step protocol — ask for a presigned
149+
target, send the bytes straight to it, then commit — so the bytes never proxy
150+
through the API server, and the same shape works for S3, local disk, or any
151+
other adapter.
152+
153+
| Method | Endpoint | SDK | Description |
154+
|:-------|:---------|:----|:------------|
155+
| POST | `/storage/upload/presigned` | `storage.getPresignedUrl` | Step 1 — mint an upload target for a new `sys_file` row |
156+
| POST | `/storage/upload/complete` | `storage.upload` | Step 3 — commit the uploaded bytes |
157+
| POST | `/storage/upload/chunked` | `storage.initChunkedUpload` | Open a chunked / resumable session |
158+
| PUT | `/storage/upload/chunked/:uploadId/chunk/:chunkIndex` | `storage.uploadPart` | Send one chunk |
159+
| POST | `/storage/upload/chunked/:uploadId/complete` | `storage.completeChunkedUpload` | Assemble the parts |
160+
| GET | `/storage/upload/chunked/:uploadId/progress` | `storage.resumeUpload` | Read session progress (first step of a resume) |
161+
| GET | `/storage/files/:fileId/url` | `storage.getDownloadUrl` | Resolve a short-lived signed download URL |
162+
| GET | `/storage/files/:fileId` || Stable browser URL; 302s to the same signed URL (what file fields carry) |
163+
164+
`storage.upload(file)` runs steps 1–3 for you, including the direct-to-storage
165+
`PUT` in step 2.
166+
167+
<Callout type="warn">
168+
**This table used to list two routes that never worked (#4087).** `POST
169+
/storage/upload` and `GET /storage/file/:id` were a dispatcher-side bridge to
170+
the `file-storage` service, written against a service shape that does not
171+
exist: it called `upload(key, data, options?)` as `upload(file, { request })`
172+
a `TypeError` against every implementation in the repo — and read the `Buffer`
173+
that `download(key)` resolves as if it were a `{ url | stream | mimeType }`
174+
descriptor. They stayed mounted and reachable — nothing shadowed them — so
175+
anyone who followed this table got a 500; everyone who followed the SDK used
176+
the protocol above and never touched them.
177+
178+
Both routes are retired. `/storage` is service-storage's surface — install that
179+
package to get it. Discovery advertises the route only when the occupant of the
180+
`file-storage` slot actually mounts HTTP handlers, so an in-memory dev
181+
implementation now reports `handlerReady: false` and no `routes.storage`
182+
instead of pointing at a path with nothing behind it.
183+
</Callout>
151184

152185
---
153186

0 commit comments

Comments
 (0)