Skip to content

Commit 96bbd82

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/console-screen-flow-submit-jfpjy4
2 parents 883f9ac + 554ff92 commit 96bbd82

14 files changed

Lines changed: 823 additions & 142 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/adapter-hono": patch
3+
---
4+
5+
fix(adapters/hono): the auth wildcard yields paths the auth service does not own (#4117)
6+
7+
`app.all('${prefix}/auth/*')` claimed a whole namespace and was **terminal**: it
8+
returned the auth service's response unconditionally, including better-auth's 404
9+
for a path it does not implement, and the legacy `handleAuth` bridge's own
10+
`handled: false` 404. That is the #4088 shape, found by #4116's enumeration after
11+
manual greps had missed it.
12+
13+
A 404 from better-auth, or `handled: false` from the dispatcher, now means "not
14+
this mount's path" and the handler yields. The predicate is the dispatcher's own
15+
`handled` flag wherever one exists — an explicit ownership answer beats inferring
16+
one from a status; only the better-auth hand-off lacks such a flag, and there the
17+
404 is the signal, as in #4092.
18+
19+
**What changes on the wire.** An unowned path under `${prefix}/auth/*` used to get
20+
a 404 built by this mount. It now continues to the `${prefix}/*` dispatcher
21+
catch-all and gets a real, gate-carrying `dispatch()` attempt, so a domain handler
22+
registered for such a path becomes reachable — this adapter's actual extension
23+
mechanism. When nothing anywhere claims the path the reply is still the same
24+
enveloped `{ success: false, error: { message: 'Not Found', code: 404 } }`. Paths
25+
the auth service does own are untouched, and a 401/403 from it is never treated as
26+
a disclaimer of ownership.
27+
28+
No configuration changes and no new routes.
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: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4117 — the `/auth/*` mount must yield paths it does not own.
5+
*
6+
* It claims a whole namespace and used to be TERMINAL: it answered 404 for a path
7+
* its auth service does not implement. Found by #4116's scan, which no manual grep
8+
* had managed. Its `/storage/*` twin was ratcheted alongside it and is now gone
9+
* entirely — #4087/#4112 deleted that bridge after reaching the same conclusion
10+
* from the other direction ("the wildcard was wider than the two routes it
11+
* served").
12+
*
13+
* ## What yielding buys HERE, precisely
14+
*
15+
* Not what it bought in #4092. There, yielding made another plugin's route
16+
* reachable. In this adapter it cannot: the `${prefix}/*` dispatcher catch-all
17+
* is registered right after these two and is DELIBERATELY terminal (ADR-0076
18+
* OQ#9, #3576/#3608 — the gate stages live inside `dispatch()`, so splitting it
19+
* into per-prefix Hono mounts would bypass them). So a route registered later
20+
* under `/api/auth/*` is swallowed by the catch-all whether or not these two
21+
* yield, and Hono-route mounting is not this adapter's extension path at all —
22+
* registering a domain handler is.
23+
*
24+
* What yielding buys is that an unowned `/auth/*` or `/storage/*` path now
25+
* reaches that gated `dispatch()` instead of dead-ending in a 404 built two
26+
* mounts earlier. A domain handler registered for such a path becomes
27+
* reachable, which is exactly the adapter's intended mechanism. These tests pin
28+
* that, and pin that the owners still win everything they own.
29+
*/
30+
31+
import { describe, it, expect, vi, beforeEach } from 'vitest';
32+
import type { Hono } from 'hono';
33+
34+
const mockDispatcher = {
35+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }),
36+
handleAuth: vi.fn(),
37+
handleGraphQL: vi.fn(),
38+
dispatch: vi.fn(),
39+
};
40+
41+
vi.mock('@objectstack/runtime', () => ({
42+
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
43+
}));
44+
45+
import { createHonoApp } from './index';
46+
47+
/** No domain claimed the path — the dispatcher's own ownership signal. */
48+
const UNHANDLED = { handled: false };
49+
const HANDLED = (body: unknown, status = 200) => ({ handled: true, response: { body, status } });
50+
51+
/** A kernel whose `auth` service is (or is not) present. */
52+
const kernelWith = (authService?: unknown) => ({
53+
name: 'test-kernel',
54+
getService: (n: string) => (n === 'auth' && authService ? authService : undefined),
55+
}) as any;
56+
57+
const authServiceReturning = (status: number, body: unknown = { from: 'better-auth' }) => ({
58+
handleRequest: vi.fn(async () => new Response(JSON.stringify(body), {
59+
status,
60+
headers: { 'Content-Type': 'application/json' },
61+
})),
62+
});
63+
64+
describe('auth mount yields paths better-auth does not own (#4117)', () => {
65+
beforeEach(() => {
66+
vi.clearAllMocks();
67+
mockDispatcher.dispatch.mockResolvedValue(UNHANDLED);
68+
mockDispatcher.handleAuth.mockResolvedValue(UNHANDLED);
69+
});
70+
71+
it('reaches the gated dispatch when better-auth 404s', async () => {
72+
// `/auth/me/permissions` is the canonical unowned path: nothing in
73+
// better-auth serves it. Before #4117 this 404'd here; now the request gets
74+
// a real dispatch attempt, so a domain handler for it is reachable.
75+
mockDispatcher.dispatch.mockResolvedValue(HANDLED({ authenticated: true, from: 'dispatch' }));
76+
const app: Hono = createHonoApp({ kernel: kernelWith(authServiceReturning(404)) });
77+
78+
const res = await app.request('/api/auth/me/permissions');
79+
80+
expect(mockDispatcher.dispatch).toHaveBeenCalled();
81+
expect(res.status).toBe(200);
82+
expect(await res.json()).toEqual({ authenticated: true, from: 'dispatch' });
83+
});
84+
85+
it('keeps better-auth winning the paths it DOES own', async () => {
86+
const app: Hono = createHonoApp({ kernel: kernelWith(authServiceReturning(200, { user: null })) });
87+
88+
const res = await app.request('/api/auth/get-session');
89+
90+
expect(res.status).toBe(200);
91+
expect(await res.json()).toEqual({ user: null });
92+
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
93+
});
94+
95+
it('does NOT yield on 401 — a real answer, not a disclaimer of ownership', async () => {
96+
const app: Hono = createHonoApp({ kernel: kernelWith(authServiceReturning(401, { error: 'nope' })) });
97+
98+
const res = await app.request('/api/auth/protected');
99+
100+
expect(res.status).toBe(401);
101+
expect(await res.json()).toEqual({ error: 'nope' });
102+
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
103+
});
104+
105+
it('yields on the dispatcher path too, keyed on `handled: false`', async () => {
106+
// No auth service at all, so the mount falls back to `dispatcher.handleAuth`
107+
// — whose explicit `handled` flag beats inferring ownership from a status.
108+
mockDispatcher.dispatch.mockResolvedValue(HANDLED({ currency: 'USD' }));
109+
const app: Hono = createHonoApp({ kernel: kernelWith() });
110+
111+
const res = await app.request('/api/auth/me/localization');
112+
113+
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
114+
expect(mockDispatcher.dispatch).toHaveBeenCalled();
115+
expect(res.status).toBe(200);
116+
expect(await res.json()).toEqual({ currency: 'USD' });
117+
});
118+
119+
it('still lets handleAuth answer what it DOES handle', async () => {
120+
mockDispatcher.handleAuth.mockResolvedValue(HANDLED({ ok: true }));
121+
const app: Hono = createHonoApp({ kernel: kernelWith() });
122+
123+
const res = await app.request('/api/auth/login', { method: 'POST' });
124+
125+
expect(res.status).toBe(200);
126+
expect(await res.json()).toEqual({ ok: true });
127+
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
128+
});
129+
});
130+
131+
describe('the enveloped 404 survives when nothing anywhere claims the path', () => {
132+
beforeEach(() => vi.clearAllMocks());
133+
134+
it('still ends in the enveloped 404 when nothing anywhere claims it', async () => {
135+
mockDispatcher.handleAuth.mockResolvedValue(UNHANDLED);
136+
mockDispatcher.dispatch.mockResolvedValue(UNHANDLED);
137+
const app: Hono = createHonoApp({ kernel: kernelWith() });
138+
139+
const res = await app.request('/api/auth/nope');
140+
141+
expect(res.status).toBe(404);
142+
// Not Hono's bare "404 Not Found" text — the platform envelope is preserved.
143+
expect(await res.json()).toEqual({ success: false, error: { message: 'Not Found', code: 404 } });
144+
});
145+
});

packages/adapters/hono/src/index.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,8 +275,39 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
275275
return c.redirect(prefix);
276276
});
277277

278+
/**
279+
* Hand a path THIS mount does not own to whatever else matched (#4117).
280+
*
281+
* The `${prefix}/auth/*` mount below claims a whole namespace and used to be
282+
* TERMINAL — it answered 404 for a path its auth service does not implement.
283+
* That is #4088's shape, which cost four fixes before #4116's scan started
284+
* enumerating it, and it is what #4087/#4112 had already concluded about the
285+
* `/storage` bridge it deleted: "the wildcard was wider than the two routes it
286+
* served".
287+
*
288+
* What yielding buys HERE is narrower than in #4092, and worth stating so
289+
* nobody over-reads it. It does NOT make a later-registered Hono route
290+
* reachable: the `${prefix}/*` dispatcher catch-all below is DELIBERATELY
291+
* terminal (ADR-0076 OQ#9, #3576/#3608 — the gate stages live inside
292+
* `dispatch()`), so it would swallow such a route either way, and mounting Hono
293+
* routes is not this adapter's extension path anyway. It means an unowned
294+
* `/auth/*` path now reaches that gated `dispatch()` instead of dead-ending in
295+
* a 404 built one mount earlier, so a domain handler registered for it becomes
296+
* reachable — which IS the adapter's mechanism.
297+
*
298+
* `c.res = …` rather than `return …`: `app.use('*', cors(…))` above puts a
299+
* middleware in this chain, and Hono's compose only assigns a handler's
300+
* RETURNED Response while `c.finalized` is false. Reaching the end of the chain
301+
* runs notFound, which sets a response and flips that flag, so a `return` here
302+
* is silently dropped (learned the hard way in #4092).
303+
*/
304+
const yieldUnowned = async (c: any, next: any, fallback: () => Response) => {
305+
await next();
306+
if (!c.res) c.res = fallback();
307+
};
308+
278309
// --- Auth (needs auth service integration) ---
279-
app.all(`${prefix}/auth/*`, async (c) => {
310+
app.all(`${prefix}/auth/*`, async (c, next) => {
280311
try {
281312
const path = c.req.path.substring(`${prefix}/auth/`.length);
282313
const method = c.req.method;
@@ -330,17 +361,25 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
330361

331362
if (authService && typeof authService.handleRequest === 'function') {
332363
const response = await authService.handleRequest(c.req.raw);
333-
return new Response(response.body, {
364+
const forwarded = () => new Response(response.body, {
334365
status: response.status,
335366
headers: response.headers,
336367
});
368+
// 404 from better-auth means "not one of my endpoints" — the #4092
369+
// signal. `/auth/me/permissions` is the canonical example: nothing in
370+
// better-auth serves it, `plugin-hono-server` does.
371+
if (response.status === 404) return yieldUnowned(c, next, forwarded);
372+
return forwarded();
337373
}
338374

339375
// Fallback to legacy dispatcher
340376
const body = method === 'GET' || method === 'HEAD'
341377
? {}
342378
: await c.req.json().catch(() => ({}));
343379
const result = await dispatcher.handleAuth(path, method, body, { request: c.req.raw });
380+
// `handled: false` is the dispatcher saying no auth domain claimed it —
381+
// an explicit ownership signal, better than inferring one from a status.
382+
if (!result.handled) return yieldUnowned(c, next, () => toResponse(c, result));
344383
return toResponse(c, result);
345384
} catch (err: any) {
346385
return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500);

packages/cli/src/commands/serve.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,13 +1020,37 @@ export default class Serve extends Command {
10201020
const configHasMetadata = !!(
10211021
config.objects || config.manifest || config.apps || config.flows || config.apis
10221022
);
1023+
// ORDERING (#4085): the wrap is APPENDED to `plugins` rather than
1024+
// registered here, because plugin registration order IS the kernel's
1025+
// init/start order (`resolveDependencies` preserves insertion order for
1026+
// plugins that declare no `dependencies`, and AppPlugin declares none).
1027+
// AppPlugin.init() registers its manifest through the `manifest` service
1028+
// and AppPlugin.start() seeds through the default datasource — both owned
1029+
// by plugins that live in `plugins[]` (ObjectQLPlugin /
1030+
// DefaultDatasourcePlugin, contributed by `createStandaloneStack`) and
1031+
// registered by the loop far below. Registering the wrap HERE put it
1032+
// ahead of them, so config-boot died in Phase 1 with
1033+
// "Service 'manifest' is async - use await" whenever no compiled
1034+
// `dist/objectstack.json` existed — the artifact path never hit it only
1035+
// because `createStandaloneStack` appends ITS AppPlugin after the engine
1036+
// (which also made the crash look artifact-related rather than
1037+
// order-related). Appending puts the config-derived app in exactly that
1038+
// same slot, so both boot paths share one plugin order.
10231039
if (!hasAppPluginAlready && configHasMetadata) {
10241040
try {
10251041
const { AppPlugin } = await import('@objectstack/runtime');
1026-
await kernel.use(new AppPlugin(config));
1027-
trackPlugin('App');
1042+
plugins = [...plugins, new AppPlugin(config)];
10281043
} catch (e: any) {
1029-
// silent
1044+
// Non-fatal — the platform still boots, just without this app's
1045+
// metadata. But it must SAY so: this catch was silent, and the two
1046+
// things it swallows (a malformed envelope AppPlugin rejects by
1047+
// construction, an unresolvable @objectstack/runtime) both leave a
1048+
// server answering with zero objects and no stated reason — the
1049+
// same class of invisible boot failure as #4085 itself.
1050+
console.warn(chalk.yellow(
1051+
` ⚠ Skipped registering the app defined in this config: ${e?.message ?? e}\n`
1052+
+ ' Its objects/flows will NOT be served. Fix the config (or pin an AppPlugin in `plugins`).',
1053+
));
10301054
}
10311055
}
10321056

0 commit comments

Comments
 (0)