Skip to content

Commit cd9f934

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-13523-history-door-schema-rebind
2 parents 0eceaf7 + c8e5ac6 commit cd9f934

29 files changed

Lines changed: 1503 additions & 321 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/hono": patch
3+
---
4+
5+
`createHonoApp` no longer discards the status and body of a dispatcher result that is already a `Response` — it hands the object on unchanged.
6+
7+
`HttpDispatcherResult.result` is declared for direct response objects ("For flexible return types or direct response objects (Response/NextResponse)"), and the runtime really puts one there: the `/auth` domain returns whatever the auth service answered as `{ handled: true, result: response }`. The adapter's `toResponse` had no arm for that. It tested `result.type` for the `redirect` and `stream` descriptors, a `Response` spells neither, and the fall-through was `c.json(res, 200)` — so the real status was replaced by a literal `200` and the real body by `JSON.stringify` of a `Response`, which is `{}` because a `Response` has no own enumerable properties.
8+
9+
Measured on a real boot through this adapter (a real kernel, the real dispatcher, `prefix: '/api/v1'`), an auth service answering an honest 404 on a path it does not serve:
10+
11+
```
12+
GET /api/v1/auth/me/permissions
13+
the door answered : 404 {"message":"Not found","code":"NOT_FOUND"}
14+
the caller read : 200 {}
15+
```
16+
17+
A discarded status is not a missing answer, it is a wrong one that reads as success: `res.ok`, `status === 200` and "nothing threw" all report a refusal, a 404 or a 500 as a completed operation, and a fail-closed guard written as `if (!data) return false` does not fire on `{}` because `{}` is truthy. Callers embedding this adapter now see the status and the body the door actually produced, along with its headers, and a non-JSON body arrives byte-identical instead of being re-serialized.
18+
19+
The check is `instanceof Response` and nothing else: the `redirect` and `stream` descriptor arms, the plain-object rendering after them, and the separate `response` arm all behave exactly as before.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli)!: retire `os create example` — it was a weaker `os init` plus a README, and the refusal now names `os init` (#16483, #15531)
6+
7+
**BREAKING** — a published CLI surface is removed. `os create example <name>` is a
8+
command a user can run today and cannot run after this release. Graded `minor`
9+
rather than `major` under this repo's lockstep launch-window convention (no
10+
package here has ever shipped a `major` changeset); the break is stated here
11+
instead of in the number.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) the retired surface is a CLI subcommand, not authored metadata: no Zod schema, no `packages/spec` declaration, no authorable key and no stored `sys_metadata` shape changes, so `objectstack migrate meta` has nothing to rewrite and the ledger has no step to carry. The channel that reaches every affected caller is the command itself, which now exits non-zero naming `os init`. -->
14+
15+
**No alias and no deprecation window.** `os create example` will not come back,
16+
so change the command rather than pinning an older CLI. (Those terms are recorded
17+
on card #16483 and are pending maintainer confirmation — the removal itself is
18+
settled by the #15531 batch entry below. The behaviour ships either way, and it is
19+
the same shape `os g agent` already shipped.)
20+
21+
#15531 rendered the real emission of both scaffolder families and hashed it file
22+
by file. The only template-level duplication left between them was this one
23+
template: `os create example` wrote a **subset** of what `os init` writes, plus
24+
one README. The two families' emission policy is already unified through four
25+
shared exports, so the remaining duplicate was the template itself — and the
26+
ruling (decision batch #66, option B) is that it goes, not that the two command
27+
families merge. They emit two different artifacts: a kernel code `Plugin` is not
28+
a declarative app, and collapsing them would make that collision structural.
29+
30+
**What to run instead**
31+
32+
```bash
33+
os init <name> # a full application project
34+
os init <name> -t empty # config only, no src/objects
35+
```
36+
37+
`os init` writes the same `tsconfig.json` the retired template did (byte-identical,
38+
measured) and an **equivalent** `objectstack.config.ts` — both manifests are
39+
`ManifestSchema`-valid but they are not the same bytes: the retired template wrote
40+
`name: '<name>'`, `description: '<name> example application'` and commented-out
41+
barrels, where `os init` writes a title-cased `name`, an empty `description` and no
42+
barrels. On top of that `os init` adds `src/objects`, a `.gitignore` and the
43+
dependency install the retired template never had.
44+
45+
**`os create plugin` is unaffected.** It scaffolds the kernel code `Plugin`
46+
contract — `src/index.ts` exporting a `Plugin` with `init` / `destroy`, built by
47+
`tsc`, publishable as `@objectstack/plugin-<name>` — which `os init` does not
48+
emit. `os create`'s flags and its standalone emission policy are unchanged for
49+
`plugin`. ⚠️ `--in-repo` is narrowed rather than untouched: the flag survives, but
50+
its `examples/<name>` placement is removed with the template and gets no
51+
replacement — `--in-repo` now only ever lands in `packages/plugins/plugin-<name>`.
52+
53+
**The removal is a signpost, not a deletion.** `os create example` still answers:
54+
it exits **1** and names `os init`, rather than falling through to the generic
55+
`Unknown type:` roster. A reader arriving from an older tutorial or a CI script
56+
that still calls it learns what replaced it instead of learning only that their
57+
spelling is off the list. Pinned end-to-end by driving the real CLI in
58+
`packages/cli/test/create-example-retired.e2e.test.ts`, which asserts both halves
59+
— the non-zero exit **and** the message naming `os init`. The four public doc
60+
pages are held to the same promise by a SEPARATE pin,
61+
`packages/cli/test/create-example-retired-docs-parity.test.ts`: it spawns nothing,
62+
so unlike the `.e2e` file it runs in the per-PR tier rather than the nightly one.

content/docs/deployment/cli.mdx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,7 +1241,7 @@ only.
12411241
| Command | Alias | Description |
12421242
|---------|-------|-------------|
12431243
| `os generate <type> <name>` | `os g` | Generate metadata files |
1244-
| `os create <type> [name]` | | Scaffold a standalone plugin or example project |
1244+
| `os create <type> [name]` | | Scaffold a standalone **kernel code** plugin project |
12451245

12461246
#### `os generate` (alias: `os g`)
12471247

@@ -1314,19 +1314,31 @@ third-party extension primitive, authored as `src/skills/<name>.skill.ts` with
13141314

13151315
#### `os create`
13161316

1317-
Scaffolds a **standalone** project — a **kernel code** plugin (the `Plugin` contract,
1317+
Scaffolds a **standalone** **kernel code** plugin project (the `Plugin` contract,
13181318
built by `tsc`, publishable; *not* the metadata plugin `os init -t plugin` emits — see
1319-
[Which scaffolder?](#os-init)), or an example application — into the current directory:
1319+
[Which scaffolder?](#os-init)) into the current directory:
13201320

13211321
```bash
13221322
os create plugin analytics # Create ./plugin-analytics
1323-
os create example my-app # Create ./my-app
13241323

13251324
cd plugin-analytics
13261325
pnpm install
13271326
pnpm build
13281327
```
13291328

1329+
<Callout type="warn" title="`os create example` is retired">
1330+
Use [`os init`](#os-init) to scaffold an application. `os create example` emitted a
1331+
subset of what `os init` writes plus one README, so it was withdrawn in
1332+
[#16483](https://github.com/objectstack-ai/objectstack/issues/16483) rather than kept as
1333+
a second, weaker way to do the same thing — with **no alias and no deprecation window**.
1334+
Running it now exits non-zero and names `os init`.
1335+
1336+
```bash
1337+
os init my-app # a full application project
1338+
os init my-app -t empty # config only, no src/objects
1339+
```
1340+
</Callout>
1341+
13301342
The emitted `package.json` declares its `@objectstack/*` dependencies as
13311343
published semver ranges pinned to the version of the CLI that generated it, and
13321344
the emitted `tsconfig.json` is self-contained, so the project installs and
@@ -1335,9 +1347,8 @@ builds anywhere — a workspace around it is neither needed nor assumed.
13351347
**Options:**
13361348
- `-d, --dir <directory>` — Write the project here instead of `./<name>`
13371349
- `--in-repo` — Scaffold **inside an ObjectStack monorepo checkout** instead
1338-
(`packages/plugins/<name>` for a plugin, `examples/<name>` for an example),
1339-
with `workspace:*` dependencies and a `tsconfig.json` that extends the
1340-
repository root config. For ObjectStack platform work only: the project it
1350+
(`packages/plugins/plugin-<name>`), with `workspace:*` dependencies and a
1351+
`tsconfig.json` that extends the repository root config. For ObjectStack platform work only: the project it
13411352
writes installs nowhere else, and the command refuses the flag when the
13421353
current directory is not a pnpm workspace root.
13431354

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#16383] `toResponse` returns a `HttpDispatcherResult.result` that IS a
5+
* `Response` unchanged — its real status, its real body, its real headers.
6+
*
7+
* ## The defect
8+
*
9+
* `HttpDispatcherResult.result` is DECLARED for direct response objects
10+
* (`packages/runtime/src/http-dispatcher.ts`: "For flexible return types or
11+
* direct response objects (Response/NextResponse)"), and the runtime really
12+
* puts one there — `runtime/src/domains/auth.ts` hands back whatever the auth
13+
* service answered as `{ handled: true, result: response }`.
14+
*
15+
* `toResponse` had no arm for that. It tested `result.type === 'redirect'` and
16+
* `result.type === 'stream'`, and everything else fell into `c.json(res, 200)`.
17+
* A Fetch `Response` has no own enumerable properties, so `JSON.stringify` of
18+
* one is `{}`, and the `200` was a literal:
19+
*
20+
* door answers 404 {"message":"Not found","code":"NOT_FOUND"}
21+
* caller reads 200 {}
22+
*
23+
* ⭐ The failure direction is what makes this a p1 rather than a cosmetic loss.
24+
* A discarded status is not a missing answer, it is a WRONG answer that reads
25+
* as success — `res.ok`, `status === 200` and "nothing threw" all report a
26+
* refusal as a completed operation — and it DEFEATS fail-closed guards instead
27+
* of merely missing them: objectui's `MePermissionsProvider.tsx` refuses on
28+
* `if (!data) return false`, and `{}` is truthy.
29+
*
30+
* ⇒ Every case below asserts the real status AND the real body. A pin that
31+
* asserted only "not 200" would stay green on a repair that answered some other
32+
* wrong status with the body still destroyed.
33+
*
34+
* ## What this file is, and what its sibling is
35+
*
36+
* This package's vitest config aliases `@objectstack/runtime` to a stub, so the
37+
* dispatcher here is a fixture — which is exactly what lets these cases drive
38+
* `toResponse`'s `result` arm over statuses and body shapes the real
39+
* composition cannot reach on demand. The other half is a REAL boot, in
40+
* `packages/qa/http-conformance/src/hono-dispatcher-result-response.conformance.test.ts`:
41+
* a real `LiteKernel`, the real `HttpDispatcher`, the real `/auth` domain, one
42+
* wire reading. `@objectstack/hono` has no in-repo consumer (#4117), so that
43+
* boot is the only thing there is to observe this through; neither file
44+
* replaces the other.
45+
*
46+
* ⛔ Not this card, deliberately untouched: which paths the dispatcher CLAIMS
47+
* (#16026), WHERE auth is mounted (#16025), and the escaped ADR-0112 envelope
48+
* on the same function's error exit (#16545).
49+
*/
50+
51+
import { describe, it, expect, vi, beforeEach } from 'vitest';
52+
import type { Hono } from 'hono';
53+
54+
const mockDispatcher = {
55+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
56+
handleAuth: vi.fn(),
57+
dispatch: vi.fn(),
58+
};
59+
60+
vi.mock('@objectstack/runtime', () => ({
61+
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
62+
}));
63+
64+
import { createHonoApp } from './index';
65+
66+
const PREFIX = '/api/v1';
67+
/** A path no explicit mount claims, so it lands on the `${prefix}/*` catch-all. */
68+
const PATH = `${PREFIX}/data/thing`;
69+
70+
const kernel = { name: 'test-kernel' } as any;
71+
const bootApp = (): Hono => createHonoApp({ kernel, prefix: PREFIX });
72+
73+
const jsonResponse = (status: number, body: unknown, headers: Record<string, string> = {}) =>
74+
new Response(JSON.stringify(body), {
75+
status,
76+
headers: { 'Content-Type': 'application/json', ...headers },
77+
});
78+
79+
describe('#16383: toResponse passes a `result` that is already a Response through', () => {
80+
beforeEach(() => {
81+
vi.clearAllMocks();
82+
mockDispatcher.handleAuth.mockResolvedValue({ handled: false });
83+
});
84+
85+
// The statuses a door really produces. 200 is carried too: a repair that
86+
// special-cased "non-200" would leave the success path rebuilt and its body
87+
// re-serialized, which is the same defect wearing the other sign.
88+
it.each([200, 201, 302, 400, 401, 403, 404, 409, 422, 500, 503])(
89+
'a %i Response reaches the caller with that status and its own body',
90+
async (status) => {
91+
const body = { message: `answer-${status}`, code: 'DOOR_SAID_SO' };
92+
mockDispatcher.dispatch.mockResolvedValue({
93+
handled: true,
94+
result: jsonResponse(status, body, { 'X-Door': 'dispatcher' }),
95+
});
96+
97+
const res = await bootApp().request(`http://localhost${PATH}`, { redirect: 'manual' });
98+
99+
expect(res.status).toBe(status);
100+
// ⭐ The body half. `{}` is what the defect produced, and it is TRUTHY —
101+
// asserting the status alone would pass on a door that still destroys it.
102+
await expect(res.clone().json()).resolves.toEqual(body);
103+
await expect(res.clone().text()).resolves.not.toBe('{}');
104+
expect(res.headers.get('x-door')).toBe('dispatcher');
105+
},
106+
);
107+
108+
it('does not re-serialize — a non-JSON body arrives byte-identical', async () => {
109+
// `c.json(res, 200)` could not have produced this at all: the body is not
110+
// JSON and its content-type is not `application/json`. A repair that
111+
// rebuilt the Response from a parsed body would corrupt both.
112+
const payload = 'id,name\n1,ada\n';
113+
mockDispatcher.dispatch.mockResolvedValue({
114+
handled: true,
115+
result: new Response(payload, {
116+
status: 418,
117+
headers: { 'Content-Type': 'text/csv; charset=utf-8' },
118+
}),
119+
});
120+
121+
const res = await bootApp().request(`http://localhost${PATH}`);
122+
123+
expect(res.status).toBe(418);
124+
expect(res.headers.get('content-type')).toBe('text/csv; charset=utf-8');
125+
await expect(res.text()).resolves.toBe(payload);
126+
});
127+
128+
it('a bodyless refusal stays bodyless — no `{}` is invented for it', async () => {
129+
// better-call answers an unrouted path exactly this way, and it is the
130+
// shape `hono-auth-owned-404.test.ts` calls `unrouted404`.
131+
mockDispatcher.dispatch.mockResolvedValue({
132+
handled: true,
133+
result: new Response(null, { status: 404, statusText: 'Not Found' }),
134+
});
135+
136+
const res = await bootApp().request(`http://localhost${PATH}`);
137+
138+
expect(res.status).toBe(404);
139+
await expect(res.text()).resolves.toBe('');
140+
});
141+
142+
it('the auth mount\'s dispatcher fallback passes one through too', async () => {
143+
// The second door into `toResponse`: `${prefix}/auth/*` with no auth
144+
// service on the kernel falls back to `dispatcher.handleAuth`, and
145+
// `runtime/src/domains/auth.ts` is the very producer that puts a `Response`
146+
// in `result`. Both callers must render it the same way.
147+
mockDispatcher.handleAuth.mockResolvedValue({
148+
handled: true,
149+
result: jsonResponse(401, { message: 'Unauthorized', code: 'UNAUTHENTICATED' }),
150+
});
151+
152+
const res = await bootApp().request(`http://localhost${PREFIX}/auth/get-session`);
153+
154+
expect(res.status).toBe(401);
155+
await expect(res.json()).resolves.toEqual({ message: 'Unauthorized', code: 'UNAUTHENTICATED' });
156+
});
157+
158+
describe('⛔ the arms either side of it are untouched', () => {
159+
it('a plain object result is still rendered as JSON with 200', async () => {
160+
// The narrowness control. This is `hono.test.ts`'s "generic result
161+
// objects with 200 status" case, restated here so a future widening of
162+
// the passthrough (`typeof res === 'object'`, say) fails in THIS file,
163+
// next to the reason it must not.
164+
mockDispatcher.dispatch.mockResolvedValue({ handled: true, result: { foo: 'bar' } });
165+
166+
const res = await bootApp().request(`http://localhost${PATH}`);
167+
168+
expect(res.status).toBe(200);
169+
await expect(res.json()).resolves.toEqual({ foo: 'bar' });
170+
});
171+
172+
it('a redirect descriptor still redirects', async () => {
173+
mockDispatcher.dispatch.mockResolvedValue({
174+
handled: true,
175+
result: { type: 'redirect', url: 'https://example.com' },
176+
});
177+
178+
const res = await bootApp().request(`http://localhost${PATH}`, { redirect: 'manual' });
179+
180+
expect(res.status).toBe(302);
181+
expect(res.headers.get('location')).toBe('https://example.com');
182+
});
183+
184+
it('a stream descriptor still streams', async () => {
185+
mockDispatcher.dispatch.mockResolvedValue({
186+
handled: true,
187+
result: {
188+
type: 'stream',
189+
events: (async function* () { yield { tick: 1 }; })(),
190+
contentType: 'text/event-stream',
191+
},
192+
});
193+
194+
const res = await bootApp().request(`http://localhost${PATH}`);
195+
196+
expect(res.status).toBe(200);
197+
expect(res.headers.get('content-type')).toContain('text/event-stream');
198+
await expect(res.text()).resolves.toContain('data: {"tick":1}');
199+
});
200+
201+
it('the `response` arm — status + body + headers — is unchanged', async () => {
202+
mockDispatcher.dispatch.mockResolvedValue({
203+
handled: true,
204+
response: { status: 201, body: { id: 1 }, headers: { 'X-Custom': 'yes' } },
205+
});
206+
207+
const res = await bootApp().request(`http://localhost${PATH}`);
208+
209+
expect(res.status).toBe(201);
210+
expect(res.headers.get('x-custom')).toBe('yes');
211+
await expect(res.json()).resolves.toEqual({ id: 1 });
212+
});
213+
});
214+
});

0 commit comments

Comments
 (0)