Skip to content

Commit d4720ca

Browse files
authored
feat(plugin-hono-server): export the current-user endpoint registrar so a bare-adapter host can supply them (cloud#924) (#4144)
`/api/v1/auth/me/permissions`, `/auth/me/localization` and `/me/apps` are the platform's SOLE supply — `packages/rest` and `packages/runtime` register no `/me/*` route, the objectui console reads the first for its whole permission layer and the second for regional defaults, and `core/security/auth-gate.ts` allow-lists the last two as endpoints a gated user MUST still reach. #4073/#4079 freed them from `registerStandardEndpoints` but left the supply welded to `HonoServerPlugin`: a host that stands up a bare `HonoHttpServer` and registers it as `http.server` itself had no provider at all (cloud#924). Registration needs a Hono app plus a service locator, not ownership of the socket, so it moves to `./current-user-endpoints` and is exported: - `registerCurrentUserEndpoints({ rawApp, ctx, prefix? })` where `ctx` is any `{ getService, logger }` — a `PluginContext` satisfies it structurally. - Idempotent: returns `false` and registers nothing when all three paths are already served, so a host may pre-register on the raw app AND mount the plugin without dead duplicates. `every`, not `some`, so a host owning just one path still gets the other two. - `makeExecutionContextResolver` and the four permission-map shaping helpers move with it (same package-root exports, no renames). The plugin delegates from the same `kernel:ready` hook, in the same order relative to the CRUD/discovery block, so `os serve` and every plugin-mounting host are unchanged. No route removed, no response shape changed.
1 parent 6fa1827 commit d4720ca

8 files changed

Lines changed: 1077 additions & 783 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/plugin-hono-server": minor
3+
---
4+
5+
feat(plugin-hono-server): export `registerCurrentUserEndpoints` so a host without the plugin can still supply them (cloud#924)
6+
7+
`GET /api/v1/auth/me/permissions`, `/api/v1/auth/me/localization` and
8+
`/api/v1/me/apps` are the platform's **sole** supply — neither
9+
`@objectstack/rest` nor `@objectstack/runtime` registers any `/me/*` route, the
10+
objectui console reads the first for its whole permission layer and the second
11+
for regional defaults, and `core`'s auth gate allow-lists the last two as
12+
endpoints a gated user MUST still reach. #4073/#4079 freed them from the
13+
`registerStandardEndpoints` flag, but left the supply welded to
14+
`HonoServerPlugin`: a host that stands up a bare `HonoHttpServer` and registers
15+
it as `http.server` itself — rather than mounting the plugin — got no provider at
16+
all, and the console's FLS / `apiOperations` had no server-side answer on that
17+
startup path.
18+
19+
Registration needs a Hono app and a service locator, not ownership of the
20+
listening socket, so it is now a standalone module (`./current-user-endpoints`)
21+
that both shapes call:
22+
23+
```ts
24+
import { registerCurrentUserEndpoints } from '@objectstack/plugin-hono-server';
25+
26+
const httpServer = new HonoHttpServer();
27+
kernel.registerService('http.server', httpServer);
28+
registerCurrentUserEndpoints({
29+
rawApp: httpServer.getRawApp(),
30+
// any { getService, logger } — a PluginContext satisfies it structurally
31+
ctx: { getService: (n) => { try { return kernel.getService(n); } catch { return undefined; } } },
32+
});
33+
```
34+
35+
It is **idempotent**: it returns `false` and registers nothing when all three
36+
paths are already served, so a host may both call it eagerly on the raw app AND
37+
mount the plugin — the plugin's `kernel:ready` registration then no-ops instead
38+
of shadowing the host's routes with dead duplicates. Registering early matters,
39+
because Hono's only route precedence is first-registration-wins and plugin-auth
40+
mounts a `/api/v1/auth/*` wildcard that `/auth/me/*` must outrank.
41+
42+
**No behaviour change for existing hosts.** `os serve` and every host that mounts
43+
`HonoServerPlugin` register the same three routes, in the same `kernel:ready`
44+
position, with the same response shapes — the plugin now delegates to the shared
45+
registrar instead of owning a private method.
46+
47+
**Moved exports (same package, same names, no rename).** `foldWildcardSuperUser`,
48+
`clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`,
49+
`annotateEffectiveApiOperations`, `ManagedSchemaLike` and `ApiExposureSchemaLike`
50+
now live in `./current-user-endpoints` alongside the endpoint they shape. Importing
51+
them from the package root (`@objectstack/plugin-hono-server`) is unchanged; only a
52+
deep import of `.../dist/hono-plugin` would need updating, and the package exposes
53+
no such subpath.

packages/plugins/plugin-hono-server/src/current-user-endpoints.ts

Lines changed: 876 additions & 0 deletions
Large diffs are not rendered by default.

packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
annotateEffectiveApiOperations,
66
seedSuperUserRestrictedObjects,
77
type ApiExposureSchemaLike,
8-
} from './hono-plugin.js';
8+
} from './current-user-endpoints.js';
99

1010
/**
1111
* #3391 — the `/me/permissions` per-object map carries the server-resolved

packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { foldWildcardSuperUser, clampManagedObjectWrites, type ManagedSchemaLike } from './hono-plugin.js';
4+
import { foldWildcardSuperUser, clampManagedObjectWrites, type ManagedSchemaLike } from './current-user-endpoints.js';
55

66
/**
77
* ADR-0057 D10 / ADR-0092 D5 — the `/me/permissions` per-object FLS map must

packages/plugins/plugin-hono-server/src/hono-current-user-endpoints.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
// endpoints are registered whatever it says.
1818

1919
import { describe, it, expect, vi } from 'vitest';
20+
import { Hono } from 'hono';
2021
import { HonoServerPlugin } from './hono-plugin';
22+
import { currentUserRoutePaths, registerCurrentUserEndpoints } from './current-user-endpoints';
2123

2224
const ME_ROUTES = [
2325
'/api/v1/auth/me/permissions',
@@ -116,3 +118,110 @@ describe('current-user endpoints are not gated by registerStandardEndpoints (#40
116118
expect(firstMe).toBeLessThan(firstData);
117119
});
118120
});
121+
122+
// cloud#924 — #4079 freed these three from the wrong flag, but left the SUPPLY
123+
// welded to this plugin. A host that stands up a bare `HonoHttpServer` instead
124+
// of mounting the plugin got no provider at all: that is cloud's default
125+
// (Vercel/serverless) `bootKernel` branch, whose `OS_NODE_SERVE=1` sibling
126+
// mounts the real plugin — so the console's whole permission layer had a
127+
// server-side answer on one startup path and a 404 on the other. Registration
128+
// needs a Hono app and a service locator, not ownership of the socket, so the
129+
// registrar is exported and both shapes call it.
130+
131+
/** A minimal locator: no services wired, so handlers take their anon branch. */
132+
function bareCtx() {
133+
return {
134+
logger: { debug() {}, warn() {} },
135+
getService: vi.fn(() => undefined),
136+
};
137+
}
138+
139+
describe('registerCurrentUserEndpoints is usable without the plugin (cloud#924)', () => {
140+
it('mounts and answers all three on a bare Hono app', async () => {
141+
const app = new Hono();
142+
143+
expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(true);
144+
145+
for (const route of ME_ROUTES) expect(paths(app)).toContain(route);
146+
// Answering is the point — before this, the serverless branch 404'd.
147+
const permissions = await app.request('http://localhost/api/v1/auth/me/permissions');
148+
expect(permissions.status).toBe(200);
149+
expect(await permissions.json()).toEqual({ authenticated: false });
150+
const localization = await app.request('http://localhost/api/v1/auth/me/localization');
151+
expect(localization.status).toBe(200);
152+
expect(await localization.json()).toEqual({ authenticated: false });
153+
const apps = await app.request('http://localhost/api/v1/me/apps');
154+
expect(apps.status).toBe(200);
155+
expect(await apps.json()).toEqual({ apps: [] });
156+
});
157+
158+
it('honours a non-default prefix', () => {
159+
const app = new Hono();
160+
161+
registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx(), prefix: '/api/v2' });
162+
163+
expect(paths(app)).toEqual(expect.arrayContaining(currentUserRoutePaths('/api/v2')));
164+
expect(paths(app)).not.toContain('/api/v1/auth/me/permissions');
165+
});
166+
167+
it('is idempotent — a second call registers nothing', () => {
168+
const app = new Hono();
169+
170+
expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(true);
171+
expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(false);
172+
173+
for (const route of ME_ROUTES) {
174+
expect(paths(app).filter((p) => p === route)).toHaveLength(1);
175+
}
176+
});
177+
178+
it('re-registers the rest when a host owns only one of the three', () => {
179+
// `every`, not `some`: treating one host-owned path as "already provided"
180+
// would silently drop the other two.
181+
const app = new Hono();
182+
app.get('/api/v1/me/apps', (c) => c.json({ apps: ['host-owned'] }));
183+
184+
expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(true);
185+
186+
for (const route of ME_ROUTES) expect(paths(app)).toContain(route);
187+
});
188+
});
189+
190+
describe('a host that pre-registers AND mounts the plugin gets ONE registration', () => {
191+
/**
192+
* cloud's `bootKernel` reaches the raw app before `kernel.bootstrap()`, so a
193+
* host call lands ahead of the plugin's `kernel:ready` hook. The host's
194+
* registration must win (it is the one that can see the host's own service
195+
* graph) and the plugin must not append dead duplicates behind it.
196+
*/
197+
it('the host wins and the plugin adds no duplicate', async () => {
198+
const plugin = new HonoServerPlugin({ port: 0, cors: false });
199+
const rawApp = (plugin as any).server.getRawApp();
200+
const readyHooks: Array<() => unknown> = [];
201+
const ctx: any = {
202+
logger: { info() {}, debug() {}, warn() {}, error() {} },
203+
getKernel: () => ({ hasPlugin: () => false, getService: () => undefined }),
204+
registerService: () => {},
205+
hook: (event: string, fn: () => unknown) => {
206+
if (event === 'kernel:ready') readyHooks.push(fn);
207+
},
208+
getService: vi.fn(() => undefined),
209+
};
210+
211+
// Host pre-registers with a recognizable body, the way cloud's bare
212+
// `HonoHttpServer` branch does before any plugin is used.
213+
rawApp.get('/api/v1/auth/me/permissions', (c: any) => c.json({ from: 'host' }));
214+
rawApp.get('/api/v1/auth/me/localization', (c: any) => c.json({ from: 'host' }));
215+
rawApp.get('/api/v1/me/apps', (c: any) => c.json({ from: 'host' }));
216+
217+
await plugin.init(ctx);
218+
await plugin.start(ctx);
219+
for (const fn of readyHooks) await fn();
220+
221+
for (const route of ME_ROUTES) {
222+
expect(paths(rawApp).filter((p) => p === route), route).toHaveLength(1);
223+
}
224+
const res = await rawApp.request('http://localhost/api/v1/auth/me/permissions');
225+
expect(await res.json()).toEqual({ from: 'host' });
226+
});
227+
});

packages/plugins/plugin-hono-server/src/hono-discovery.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import { describe, it, expect } from 'vitest';
1616
import { HonoServerPlugin } from './hono-plugin';
17+
import { registerCurrentUserEndpoints } from './current-user-endpoints';
1718

1819
const REST_API_PLUGIN = 'com.objectstack.rest.api';
1920
const RUNTIME_DISPATCHER_PLUGIN = 'com.objectstack.runtime.dispatcher';
@@ -40,9 +41,10 @@ function bootStandardEndpoints(installedPlugins: string[] = []) {
4041
// the CRUD + discovery surface only under `registerStandardEndpoints`.
4142
// Discovery is computed from what is really mounted, so a boot that skipped
4243
// the `/auth/me/*` helpers would under-report `routes.auth`.
43-
(plugin as any).registerCurrentUserEndpoints(ctx);
44+
const rawApp = (plugin as any).server.getRawApp();
45+
registerCurrentUserEndpoints({ rawApp, ctx });
4446
(plugin as any).registerDiscoveryAndCrudEndpoints(ctx);
45-
return (plugin as any).server.getRawApp();
47+
return rawApp;
4648
}
4749

4850
async function discoveryRoutes(app: any): Promise<Record<string, string>> {

0 commit comments

Comments
 (0)