Skip to content

Commit 545d931

Browse files
authored
fix(plugin-hono-server): the current-user endpoints answer from the kernel that owns the request (cloud#927) (#4159)
`/auth/me/permissions`, `/auth/me/localization` and `/me/apps` resolved their answer from the locator captured at REGISTRATION time. On a single-environment host that is the only kernel, so it is right. On a MULTI-TENANT host it is the routing shell — and identity is not there: cloud's `ArtifactKernelFactory` mounts `AuthPlugin` per environment and its host kernel deliberately has none. So `getService('auth')` threw, the session resolver fell to its catch, and every authenticated tenant caller got `{authenticated:false}` — which objectui's `MePermissionsProvider` reads as ANONYMOUS and fails OPEN on. Never a bypass (the server enforces per request), but exactly the client/server divergence the permission-map shaping exists to close, one layer up. These endpoints now consult the host's ADR-0006 `kernel-resolver` seam per request — the same one the runtime dispatcher has used since Phase 5: - no resolver registered → unchanged (single-environment hosts, `os serve`, the QA conformance host); - a kernel → that kernel's `auth`/`objectql`/`metadata`/`security.permissions`; - `undefined` → the registration-time locator, the seam's contract for an unscoped/control-plane request; - a throw → NO answer: the thrown status when it carries one (cloud's `KernelWarmingError` is 503 + `Retry-After`), else 503. Falling back would hand back a confidently-wrong `{authenticated:false}` the client fails OPEN on. Read lazily, per request: a host may register these routes before `kernel.bootstrap()` to outrank an `/api/v1/auth/*` wildcard, which is before the plugin registering the resolver has run `init()`. The three handlers are wrapped so the per-request locator SHADOWS the registration-time `ctx`, leaving the bodies byte-identical. `CurrentUserEndpointsContext` gains an optional `getKernel()` — the `defaultKernel` the seam takes. `PluginContext` already satisfies it, so plugin-mounting hosts need no change; the changeset carries the FROM → TO for a hand-rolled locator.
1 parent 217e2e6 commit 545d931

3 files changed

Lines changed: 404 additions & 7 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/plugin-hono-server": minor
3+
---
4+
5+
fix(plugin-hono-server): the current-user endpoints answer from the kernel that OWNS the request (cloud#927)
6+
7+
`/api/v1/auth/me/permissions`, `/auth/me/localization` and `/me/apps` resolved
8+
their answer from the service locator captured at REGISTRATION time. On a
9+
single-environment host that is the only kernel, so it is right. On a
10+
**multi-tenant** host it is the routing shell — and identity is not there.
11+
cloud's `ArtifactKernelFactory` mounts `AuthPlugin` per environment, and its host
12+
kernel deliberately has none ("AuthPlugin is intentionally NOT injected on the
13+
host"), so `getService('auth')` threw, the session resolver fell to its catch, and
14+
every authenticated tenant caller got `{authenticated:false}`.
15+
16+
That is worse than an error: objectui's `MePermissionsProvider` reads
17+
`authenticated:false` as ANONYMOUS and keeps its permissive default
18+
(`return data.authenticated !== true`), because a guest surface has no resolvable
19+
permissions by design. So the console's FLS / `apiOperations` hints were
20+
systematically wrong — not a bypass (the server still enforces per request), but
21+
exactly the client/server divergence `foldWildcardSuperUser` and
22+
`clampManagedObjectWrites` exist to close, one layer up.
23+
24+
These endpoints now consult the host's ADR-0006 **`kernel-resolver`** seam per
25+
request — the same seam the runtime dispatcher has used since Phase 5, so
26+
multi-tenant routing has one strategy rather than two:
27+
28+
- **No `kernel-resolver` registered** → unchanged. Single-environment hosts,
29+
`os serve`, and the QA conformance host see no difference.
30+
- **A kernel** → that kernel's `auth` / `objectql` / `metadata` /
31+
`security.permissions` answer.
32+
- **`undefined`** → the registration-time locator, which is the seam's contract
33+
for an unscoped / control-plane request.
34+
- **A throw** → no answer at all: the thrown status when it carries one (cloud's
35+
`KernelWarmingError` is 503 + `Retry-After`), else 503
36+
`environment_unavailable`. Falling back to the default kernel would hand back a
37+
confidently-wrong `{authenticated:false}` that the client fails OPEN on.
38+
39+
The seam is read **lazily, per request**, never captured at registration — a host
40+
may register these routes before `kernel.bootstrap()` (to outrank an
41+
`/api/v1/auth/*` wildcard), which is before the plugin that registers the
42+
resolver has run its `init()`.
43+
44+
**FROM → TO for host adapters.** `CurrentUserEndpointsContext` gains an optional
45+
`getKernel(): unknown`, the `defaultKernel` argument the seam takes. A
46+
`PluginContext` already satisfies it, so hosts that mount `HonoServerPlugin` need
47+
no change. A host passing a hand-rolled locator to
48+
`registerCurrentUserEndpoints` should add it:
49+
50+
```diff
51+
registerCurrentUserEndpoints({
52+
rawApp: httpServer.getRawApp(),
53+
- ctx: { getService: (n) => kernel.getService(n) },
54+
+ ctx: { getService: (n) => kernel.getService(n), getKernel: () => kernel },
55+
});
56+
```
57+
58+
Without it a multi-tenant host cannot be asked which kernel owns the request and
59+
keeps the old provenance — a silent downgrade, so it is worth adding even where
60+
the host is single-environment today.
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// cloud#927 — WHICH kernel answers the current-user endpoints.
4+
//
5+
// These three resolved their answer from the locator captured at REGISTRATION
6+
// time. On a single-environment host that is the only kernel and therefore
7+
// right. On a multi-tenant host it is the routing shell: cloud's
8+
// `ArtifactKernelFactory` mounts `AuthPlugin` per ENVIRONMENT, and its host
9+
// kernel deliberately has no `auth` at all ("AuthPlugin is intentionally NOT
10+
// injected on the host"). So `ctx.getService('auth')` threw there, `resolveCtx`
11+
// fell to its catch, and every authenticated tenant caller got
12+
// `{authenticated:false}` — which objectui's `MePermissionsProvider` reads as
13+
// ANONYMOUS and fails OPEN on (`return data.authenticated !== true`). The
14+
// server still enforces per request, so it was not a bypass; the client's FLS
15+
// hints were just systematically wrong.
16+
//
17+
// The framework already declares the seam for this: `kernel-resolver`
18+
// (ADR-0006 Phase 5), which the dispatcher has consulted since. These endpoints
19+
// not consulting it was the defect.
20+
21+
import { describe, it, expect, vi } from 'vitest';
22+
import { Hono } from 'hono';
23+
import { registerCurrentUserEndpoints } from './current-user-endpoints';
24+
25+
const ME_PERMISSIONS = '/api/v1/auth/me/permissions';
26+
const ME_LOCALIZATION = '/api/v1/auth/me/localization';
27+
const ME_APPS = '/api/v1/me/apps';
28+
29+
/** A kernel stub whose `getService` throws for anything not seeded (like the real one). */
30+
function kernelWith(services: Record<string, any>) {
31+
return {
32+
getService: <T,>(name: string): T => {
33+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
34+
return services[name] as T;
35+
},
36+
};
37+
}
38+
39+
/** An `auth` service that resolves one fixed user. */
40+
const authFor = (userId: string, extra: Record<string, unknown> = {}) => ({
41+
api: { getSession: vi.fn(async () => ({ user: { id: userId }, session: extra })) },
42+
});
43+
44+
/**
45+
* Mount on a bare Hono app over `hostServices`. `resolveKernel` is installed as
46+
* the host's `kernel-resolver` service — after registration, so the tests also
47+
* pin that the seam is read LAZILY (cloud registers these routes before
48+
* `kernel.bootstrap()`, i.e. before the plugin that registers the resolver has
49+
* run its `init()`; capturing the service at registration time would find
50+
* nothing and silently keep the old behaviour).
51+
*/
52+
function mount(hostServices: Record<string, any>, resolveKernel?: (...a: any[]) => any) {
53+
const app = new Hono();
54+
const services: Record<string, any> = { ...hostServices };
55+
const kernel = kernelWith(services);
56+
const ctx = {
57+
logger: { debug() {}, warn() {} },
58+
getService: <T,>(name: string): T => kernel.getService<T>(name),
59+
getKernel: () => kernel,
60+
};
61+
62+
registerCurrentUserEndpoints({ rawApp: app, ctx });
63+
64+
// Registered AFTER — the lazy read is what makes this visible.
65+
if (resolveKernel) services['kernel-resolver'] = { resolveKernel };
66+
67+
return { app, kernel, services };
68+
}
69+
70+
const get = (app: any, path: string) =>
71+
app.request(`http://tenant.example.com${path}`, { headers: { host: 'tenant.example.com' } });
72+
73+
describe('the answer comes from the kernel that OWNS the request (cloud#927)', () => {
74+
it('a resolved environment kernel answers, not the routing shell', async () => {
75+
// The shell has no `auth` — exactly cloud's objectos host.
76+
const envKernel = kernelWith({ auth: authFor('usr_env') });
77+
const { app } = mount({}, async () => envKernel);
78+
79+
const res = await get(app, ME_PERMISSIONS);
80+
81+
expect(res.status).toBe(200);
82+
// Before this, the shell's missing `auth` made this `{authenticated:false}`
83+
// for every real caller — the fail-open the client then inherited.
84+
expect(await res.json()).toMatchObject({ authenticated: true, userId: 'usr_env' });
85+
});
86+
87+
it('prefers the environment kernel over a host that DOES have auth', async () => {
88+
// Pins that resolution wins rather than merely filling a gap: a host with
89+
// its own identity must not answer for a tenant request.
90+
const envKernel = kernelWith({ auth: authFor('usr_env') });
91+
const { app } = mount({ auth: authFor('usr_host') }, async () => envKernel);
92+
93+
expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_env' });
94+
});
95+
96+
it('routes /auth/me/localization and /me/apps through the same resolution', async () => {
97+
const envKernel = kernelWith({
98+
auth: authFor('usr_env'),
99+
objectql: { _registry: { getAllApps: () => [{ name: 'env_app' }] } },
100+
});
101+
const { app } = mount({}, async () => envKernel);
102+
103+
expect(await (await get(app, ME_LOCALIZATION)).json()).toMatchObject({ authenticated: true });
104+
expect(await (await get(app, ME_APPS)).json()).toEqual({ apps: [{ name: 'env_app' }] });
105+
});
106+
107+
it('hands the resolver the prefix-stripped routePath the dispatcher would', async () => {
108+
// cloud's resolver applies its own path policy to `routePath` (control-plane
109+
// prefixes skip environment resolution), so the shape has to match.
110+
const seen: any[] = [];
111+
const envKernel = kernelWith({ auth: authFor('usr_env') });
112+
const { app, kernel } = mount({}, async (context: any, defaultKernel: any) => {
113+
seen.push({ routePath: context.routePath, isDefault: defaultKernel === kernel, headers: context.request?.headers });
114+
return envKernel;
115+
});
116+
117+
await get(app, ME_PERMISSIONS);
118+
await get(app, ME_APPS);
119+
120+
expect(seen.map((s) => s.routePath)).toEqual(['/auth/me/permissions', '/me/apps']);
121+
// The seam's second argument is the host kernel, as `KernelResolver` declares.
122+
expect(seen.every((s) => s.isDefault)).toBe(true);
123+
// Cloud's resolver reads `host` / `x-environment-id` off these.
124+
expect(seen[0].headers).toBeInstanceOf(Headers);
125+
});
126+
});
127+
128+
describe('the default kernel still answers where the seam says it should', () => {
129+
it('no kernel-resolver at all — single-environment hosts are untouched', async () => {
130+
const { app } = mount({ auth: authFor('usr_host') });
131+
132+
expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' });
133+
});
134+
135+
it('resolver returns undefined — the contract for an unscoped request', async () => {
136+
const { app } = mount({ auth: authFor('usr_host') }, async () => undefined);
137+
138+
expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' });
139+
});
140+
141+
it('resolver hands back the default kernel itself', async () => {
142+
const { app, kernel } = mount({ auth: authFor('usr_host') }, async (_c: any, d: any) => d ?? kernel);
143+
144+
expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' });
145+
});
146+
147+
it('a locator with no getKernel cannot be asked, so it answers itself', async () => {
148+
// Documented downgrade, not a neutral choice — a multi-tenant host that
149+
// omits `getKernel` gets the old (wrong) provenance back.
150+
const app = new Hono();
151+
const kernel = kernelWith({ auth: authFor('usr_host'), 'kernel-resolver': { resolveKernel: vi.fn() } });
152+
registerCurrentUserEndpoints({
153+
rawApp: app,
154+
ctx: { logger: { debug() {}, warn() {} }, getService: (n) => kernel.getService(n) },
155+
});
156+
157+
expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' });
158+
});
159+
});
160+
161+
describe('a kernel that cannot be reached is a retryable error, never a fake anonymous', () => {
162+
it('surfaces a warming 503 with Retry-After', async () => {
163+
// cloud's `KernelWarmingError` shape: a cold environment kernel past the
164+
// wait budget. AuthProxyPlugin already answers 503 + Retry-After for it.
165+
// Only `statusCode` + `retryAfterSeconds` are load-bearing — its `code` is
166+
// read for the log line alone, so the fixture omits it rather than carry a
167+
// lowercase literal in a `code:` position (ADR-0112 D1).
168+
const warming = Object.assign(new Error('kernel warming'), {
169+
statusCode: 503, retryAfterSeconds: 4,
170+
});
171+
const { app } = mount({}, async () => { throw warming; });
172+
173+
const res = await get(app, ME_PERMISSIONS);
174+
175+
expect(res.status).toBe(503);
176+
expect(res.headers.get('Retry-After')).toBe('4');
177+
expect(await res.json()).toMatchObject({ error: 'environment_unavailable' });
178+
});
179+
180+
it('a resolver failure with no status is a 503, not a fall-back to the shell', async () => {
181+
// Falling back would answer `{authenticated:false}` off the shell, which the
182+
// client reads as anonymous and fails OPEN on — worse than a retryable error.
183+
const { app } = mount({ auth: authFor('usr_host') }, async () => { throw new Error('registry down'); });
184+
185+
const res = await get(app, ME_PERMISSIONS);
186+
187+
expect(res.status).toBe(503);
188+
expect(await res.json()).not.toMatchObject({ authenticated: false });
189+
});
190+
191+
it('applies to all three endpoints', async () => {
192+
const { app } = mount({}, async () => { throw new Error('registry down'); });
193+
194+
for (const path of [ME_PERMISSIONS, ME_LOCALIZATION, ME_APPS]) {
195+
expect((await get(app, path)).status, path).toBe(503);
196+
}
197+
});
198+
199+
it('passes a 4xx from the resolver through unchanged', async () => {
200+
const denied = Object.assign(new Error('nope'), { statusCode: 403 });
201+
const { app } = mount({}, async () => { throw denied; });
202+
203+
expect((await get(app, ME_PERMISSIONS)).status).toBe(403);
204+
});
205+
});

0 commit comments

Comments
 (0)