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
42 changes: 42 additions & 0 deletions .changeset/hono-current-user-endpoints-ungated.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/plugin-hono-server": minor
---

fix(plugin-hono-server): stop gating the current-user endpoints behind `registerStandardEndpoints` (#4073)

`registerStandardEndpoints` gated two unrelated things behind one flag:

- **Duplicate supply** — raw `POST/GET /api/v1/data/:object` (create + read
only), which `@objectstack/rest` also serves and, registering first, is what
actually answers; plus `GET /api/v1/discovery` and
`/.well-known/objectstack`, which the dispatcher and REST own and which this
surface already cedes to them (#4018).
- **Sole supply** — `GET /api/v1/auth/me/permissions`,
`/api/v1/auth/me/localization` and `/api/v1/me/apps`. Nothing else in the
platform mounts these: neither `@objectstack/rest` nor `@objectstack/runtime`
registers any `/me/*` route, the console's entire permission layer reads
`/auth/me/permissions`, the console reads `/auth/me/localization` for regional
defaults, and `core`'s auth gate allow-lists `/me/apps` + `/me/localization`
as endpoints a gated user MUST still reach to bootstrap the remediation UI.

`os serve` gets all of it only because the flag defaults to `true` — the CLI
constructs `new HonoServerPlugin({ port })`. So `registerStandardEndpoints:
false`, whose documented job is the optional CRUD/discovery convenience surface,
silently took the console's permissions and localization down with it.

The three current-user endpoints now register **unconditionally**, and the flag
covers the duplicate half only — what its name and docs always claimed.

**FROM → TO.** If you set `registerStandardEndpoints: false` and worked around
the missing endpoints (proxying `/auth/me/permissions` yourself, or pinning the
flag to `true` purely to keep them), you can drop that workaround: the endpoints
are now present either way. No route is removed and no response shape changes,
so a host that left the flag at its default sees no difference. If you relied on
`false` meaning "this plugin mounts no `/api/v1` routes at all", that is no
longer true — it never was for `os serve`, which is the only host that shipped
the flag's default.

Also removes three unreferenced `*_ENDPOINT_PRIORITY` constants;
`DISCOVERY_ENDPOINT_PRIORITY = 900` in particular implied a route-priority
mechanism that does not exist (precedence here is Hono's
first-registration-wins).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #4073 — `registerStandardEndpoints` used to gate two unrelated things:
//
// * DUPLICATE supply — raw `/data` C+R that `@objectstack/rest` also serves
// (and, registering first, really serves), plus a discovery the dispatcher
// and REST own (#4018).
// * SOLE supply — `/auth/me/permissions`, `/auth/me/localization`, `/me/apps`.
// Nothing else in the platform mounts these: `packages/rest` and
// `packages/runtime` register no `/me/*` route, the console's whole
// permission layer reads `/auth/me/permissions`, and `core`'s auth gate
// allow-lists `/me/apps` + `/me/localization` as endpoints a gated user MUST
// still reach. `os serve` gets them only via the flag's `true` default.
//
// So turning the flag off took the console down with it. These tests pin the
// split: the flag now covers the duplicate half only, and the current-user
// endpoints are registered whatever it says.

import { describe, it, expect, vi } from 'vitest';
import { HonoServerPlugin } from './hono-plugin';

const ME_ROUTES = [
'/api/v1/auth/me/permissions',
'/api/v1/auth/me/localization',
'/api/v1/me/apps',
];

/**
* Boot a plugin through its real `start()` and fire the `kernel:ready` hooks it
* registered — the actual wiring, not a hand-picked pair of method calls, so a
* regression that re-gates the current-user endpoints is caught here.
*/
async function boot(registerStandardEndpoints: boolean) {
const plugin = new HonoServerPlugin({ port: 0, registerStandardEndpoints, cors: false });
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),
};

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

return (plugin as any).server.getRawApp();
}

/** Registered paths on the live Hono app, ignoring middleware catch-alls. */
function paths(app: any): string[] {
return (app.routes ?? []).map((r: any) => r.path);
}

describe('current-user endpoints are not gated by registerStandardEndpoints (#4073)', () => {
it('mounts /me/* with the convenience surface OFF', async () => {
const app = await boot(false);
for (const route of ME_ROUTES) {
expect(paths(app), `${route} must survive registerStandardEndpoints:false`).toContain(route);
}
});

it('mounts /me/* with the convenience surface ON (unchanged for os serve)', async () => {
const app = await boot(true);
for (const route of ME_ROUTES) expect(paths(app)).toContain(route);
});

it('still gates the duplicate half — no /data CRUD, no /discovery when OFF', async () => {
const app = await boot(false);
const registered = paths(app);

expect(registered).not.toContain('/api/v1/data/:object');
expect(registered).not.toContain('/api/v1/discovery');
expect(registered).not.toContain('/.well-known/objectstack');
});

it('registers the duplicate half when ON', async () => {
const registered = paths(await boot(true));

expect(registered).toContain('/api/v1/data/:object');
expect(registered).toContain('/api/v1/discovery');
});

it('answers /me/* with the flag OFF instead of 404ing', async () => {
const app = await boot(false);

// No auth service is wired, so each endpoint takes its anonymous branch
// — which is a real answer, not the "route does not exist" 404 the
// console used to get when the flag was off.
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('registers /me/* BEFORE the CRUD block — the order plugin-auth collides with', async () => {
// plugin-auth mounts a TERMINAL `rawApp.all('/api/v1/auth/*')` from its
// own kernel:ready hook, so `/auth/me/*` only wins the match by being
// registered first. The split must not have moved these later.
const registered = paths(await boot(true));
const firstMe = registered.indexOf('/api/v1/auth/me/permissions');
const firstData = registered.indexOf('/api/v1/data/:object');

expect(firstMe).toBeGreaterThanOrEqual(0);
expect(firstData).toBeGreaterThanOrEqual(0);
expect(firstMe).toBeLessThan(firstData);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,12 @@ function bootStandardEndpoints(installedPlugins: string[] = []) {
hook: () => {},
getService: () => undefined,
};
// Same order `start()` wires the two `kernel:ready` hooks in: the
// current-user endpoints are registered unconditionally and first (#4073),
// 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);
(plugin as any).registerDiscoveryAndCrudEndpoints(ctx);
return (plugin as any).server.getRawApp();
}
Expand Down
Loading
Loading