Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/hono-current-user-endpoints-exported.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-hono-server": minor
---

feat(plugin-hono-server): export `registerCurrentUserEndpoints` so a host without the plugin can still supply them (cloud#924)

`GET /api/v1/auth/me/permissions`, `/api/v1/auth/me/localization` and
`/api/v1/me/apps` are the platform's **sole** supply — neither
`@objectstack/rest` nor `@objectstack/runtime` registers any `/me/*` route, the
objectui console reads the first for its whole permission layer and the second
for regional defaults, and `core`'s auth gate allow-lists the last two as
endpoints a gated user MUST still reach. #4073/#4079 freed them from the
`registerStandardEndpoints` flag, but left the supply welded to
`HonoServerPlugin`: a host that stands up a bare `HonoHttpServer` and registers
it as `http.server` itself — rather than mounting the plugin — got no provider at
all, and the console's FLS / `apiOperations` had no server-side answer on that
startup path.

Registration needs a Hono app and a service locator, not ownership of the
listening socket, so it is now a standalone module (`./current-user-endpoints`)
that both shapes call:

```ts
import { registerCurrentUserEndpoints } from '@objectstack/plugin-hono-server';

const httpServer = new HonoHttpServer();
kernel.registerService('http.server', httpServer);
registerCurrentUserEndpoints({
rawApp: httpServer.getRawApp(),
// any { getService, logger } — a PluginContext satisfies it structurally
ctx: { getService: (n) => { try { return kernel.getService(n); } catch { return undefined; } } },
});
```

It is **idempotent**: it returns `false` and registers nothing when all three
paths are already served, so a host may both call it eagerly on the raw app AND
mount the plugin — the plugin's `kernel:ready` registration then no-ops instead
of shadowing the host's routes with dead duplicates. Registering early matters,
because Hono's only route precedence is first-registration-wins and plugin-auth
mounts a `/api/v1/auth/*` wildcard that `/auth/me/*` must outrank.

**No behaviour change for existing hosts.** `os serve` and every host that mounts
`HonoServerPlugin` register the same three routes, in the same `kernel:ready`
position, with the same response shapes — the plugin now delegates to the shared
registrar instead of owning a private method.

**Moved exports (same package, same names, no rename).** `foldWildcardSuperUser`,
`clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`,
`annotateEffectiveApiOperations`, `ManagedSchemaLike` and `ApiExposureSchemaLike`
now live in `./current-user-endpoints` alongside the endpoint they shape. Importing
them from the package root (`@objectstack/plugin-hono-server`) is unchanged; only a
deep import of `.../dist/hono-plugin` would need updating, and the package exposes
no such subpath.
876 changes: 876 additions & 0 deletions packages/plugins/plugin-hono-server/src/current-user-endpoints.ts

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import {
annotateEffectiveApiOperations,
seedSuperUserRestrictedObjects,
type ApiExposureSchemaLike,
} from './hono-plugin.js';
} from './current-user-endpoints.js';

/**
* #3391 — the `/me/permissions` per-object map carries the server-resolved
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

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

/**
* ADR-0057 D10 / ADR-0092 D5 — the `/me/permissions` per-object FLS map must
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,9 @@
// endpoints are registered whatever it says.

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

const ME_ROUTES = [
'/api/v1/auth/me/permissions',
Expand DownExpand Up@@ -116,3 +118,110 @@ describe('current-user endpoints are not gated by registerStandardEndpoints (#40
expect(firstMe).toBeLessThan(firstData);
});
});

// cloud#924 — #4079 freed these three from the wrong flag, but left the SUPPLY
// welded to this plugin. A host that stands up a bare `HonoHttpServer` instead
// of mounting the plugin got no provider at all: that is cloud's default
// (Vercel/serverless) `bootKernel` branch, whose `OS_NODE_SERVE=1` sibling
// mounts the real plugin — so the console's whole permission layer had a
// server-side answer on one startup path and a 404 on the other. Registration
// needs a Hono app and a service locator, not ownership of the socket, so the
// registrar is exported and both shapes call it.

/** A minimal locator: no services wired, so handlers take their anon branch. */
function bareCtx() {
return {
logger: { debug() {}, warn() {} },
getService: vi.fn(() => undefined),
};
}

describe('registerCurrentUserEndpoints is usable without the plugin (cloud#924)', () => {
it('mounts and answers all three on a bare Hono app', async () => {
const app = new Hono();

expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(true);

for (const route of ME_ROUTES) expect(paths(app)).toContain(route);
// Answering is the point — before this, the serverless branch 404'd.
const permissions = await app.request('http://localhost/api/v1/auth/me/permissions');
expect(permissions.status).toBe(200);
expect(await permissions.json()).toEqual({ authenticated: false });
const localization = await app.request('http://localhost/api/v1/auth/me/localization');
expect(localization.status).toBe(200);
expect(await localization.json()).toEqual({ authenticated: false });
const apps = await app.request('http://localhost/api/v1/me/apps');
expect(apps.status).toBe(200);
expect(await apps.json()).toEqual({ apps: [] });
});

it('honours a non-default prefix', () => {
const app = new Hono();

registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx(), prefix: '/api/v2' });

expect(paths(app)).toEqual(expect.arrayContaining(currentUserRoutePaths('/api/v2')));
expect(paths(app)).not.toContain('/api/v1/auth/me/permissions');
});

it('is idempotent — a second call registers nothing', () => {
const app = new Hono();

expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(true);
expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(false);

for (const route of ME_ROUTES) {
expect(paths(app).filter((p) => p === route)).toHaveLength(1);
}
});

it('re-registers the rest when a host owns only one of the three', () => {
// `every`, not `some`: treating one host-owned path as "already provided"
// would silently drop the other two.
const app = new Hono();
app.get('/api/v1/me/apps', (c) => c.json({ apps: ['host-owned'] }));

expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(true);

for (const route of ME_ROUTES) expect(paths(app)).toContain(route);
});
});

describe('a host that pre-registers AND mounts the plugin gets ONE registration', () => {
/**
* cloud's `bootKernel` reaches the raw app before `kernel.bootstrap()`, so a
* host call lands ahead of the plugin's `kernel:ready` hook. The host's
* registration must win (it is the one that can see the host's own service
* graph) and the plugin must not append dead duplicates behind it.
*/
it('the host wins and the plugin adds no duplicate', async () => {
const plugin = new HonoServerPlugin({ port: 0, cors: false });
const rawApp = (plugin as any).server.getRawApp();
const readyHooks: Array<() => unknown> = [];
const ctx: any = {
logger: { info() {}, debug() {}, warn() {}, error() {} },
getKernel: () => ({ hasPlugin: () => false, getService: () => undefined }),
registerService: () => {},
hook: (event: string, fn: () => unknown) => {
if (event === 'kernel:ready') readyHooks.push(fn);
},
getService: vi.fn(() => undefined),
};

// Host pre-registers with a recognizable body, the way cloud's bare
// `HonoHttpServer` branch does before any plugin is used.
rawApp.get('/api/v1/auth/me/permissions', (c: any) => c.json({ from: 'host' }));
rawApp.get('/api/v1/auth/me/localization', (c: any) => c.json({ from: 'host' }));
rawApp.get('/api/v1/me/apps', (c: any) => c.json({ from: 'host' }));

await plugin.init(ctx);
await plugin.start(ctx);
for (const fn of readyHooks) await fn();

for (const route of ME_ROUTES) {
expect(paths(rawApp).filter((p) => p === route), route).toHaveLength(1);
}
const res = await rawApp.request('http://localhost/api/v1/auth/me/permissions');
expect(await res.json()).toEqual({ from: 'host' });
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@

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

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

async function discoveryRoutes(app: any): Promise<Record<string, string>> {
Expand Down
Loading
Loading