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
36 changes: 36 additions & 0 deletions .changeset/hono-standard-endpoints-default-off.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/plugin-hono-server": minor
---

feat(plugin-hono-server): `registerStandardEndpoints` now defaults to `false` — the deprecated CRUD/discovery convenience surface is opt-in (#4073)

The flag mounts raw C+R `/api/v1/data/:object` and `/api/v1/discovery` /
`/.well-known/objectstack`. Every path it mounts is duplicate — and lesser —
supply: C+R only, a subset of the gates, a pre-`DiscoverySchema` discovery
payload. `@objectstack/rest` serves full `/data` CRUD behind the whole gate
stack, REST/the dispatcher own discovery (#4018 cede), and #4260 pinned that a
composed host answers **byte-identically** with the flag on or off. The surface
has also been a standing tax: #2567, #3298 and #4018 each had to re-implement a
platform invariant here after the fact.

**FROM → TO**

- **Composed hosts (REST and/or the dispatcher mounted)** — `os serve`,
`objectstack dev`, cloud's objectos, every documented path: **no change**.
Those plugins already answer every route this surface covered, and answered
them first.
- **Bare hosts (HonoServerPlugin only)**: `/api/v1/data/:object`,
`/api/v1/discovery` and `/.well-known/objectstack` are **no longer mounted by
default**. The boot now logs a warn naming the flag and the remedy instead of
leaving a silent 404. Migrate by mounting `createRestApiPlugin` from
`@objectstack/rest` — it needs the same `objectql` service this surface
already required, and returns full CRUD plus the gate stack — or pass
`registerStandardEndpoints: true` to keep the legacy surface during the
deprecation window.
- The current-user endpoints (`/auth/me/permissions`, `/auth/me/localization`,
`/me/apps`) are **unaffected** — they never sat behind this flag (#4144) and
register unconditionally.

The flag is now marked `@deprecated`. Next step per #4073: one release of
observation, then `registerDiscoveryAndCrudEndpoints` (and the flag) are deleted
and this plugin becomes a pure transport adapter (ADR-0076 D11).
8 changes: 8 additions & 0 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import { DriverPlugin } from '@objectstack/runtime';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { AuthPlugin } from '@objectstack/plugin-auth';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
import { createRestApiPlugin } from '@objectstack/rest';

const kernel = new ObjectKernel();

Expand All @@ -141,6 +142,13 @@ await kernel.use(new HonoServerPlugin({
port: 3000,
}));

// Data API — serves /api/v1/data/:object with full CRUD behind the gate
// stack. Without it this minimal stack has no data routes: the HTTP server
// plugin is a transport adapter and its legacy built-in data surface is
// deprecated and off by default (#4073). The authenticated fetch further
// down this page reads /api/v1/data/task through this plugin.
await kernel.use(createRestApiPlugin());

// Authentication plugin
await kernel.use(new AuthPlugin({
secret: process.env.OS_AUTH_SECRET,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ function bootStandardEndpoints(opts: {
restConfig?: { api?: { requireAuth?: boolean } };
services: Record<string, unknown>;
}) {
const plugin = new HonoServerPlugin({ port: 0, restConfig: opts.restConfig as any });
// Explicit opt-in: the raw surface whose #2567 gate is under test defaults
// OFF now (#4073).
const plugin = new HonoServerPlugin({
port: 0,
registerStandardEndpoints: true,
restConfig: opts.restConfig as any,
});
const ctx: any = {
logger: { info() {}, debug() {}, warn() {}, error() {} },
getKernel: () => ({ getService: (n: string) => opts.services[n] }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ const RUNTIME_DISPATCHER_PLUGIN = 'com.objectstack.runtime.dispatcher';
* which is how this surface decides whether a real discovery owner is present.
*/
function bootStandardEndpoints(installedPlugins: string[] = []) {
const plugin = new HonoServerPlugin({ port: 0 });
// Explicit opt-in: the legacy surface under test defaults OFF now (#4073).
const plugin = new HonoServerPlugin({ port: 0, registerStandardEndpoints: true });
const ctx: any = {
logger: { info() {}, debug() {}, warn() {}, error() {} },
getKernel: () => ({
Expand Down
56 changes: 49 additions & 7 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,32 @@ export interface HonoPluginOptions {
* raw `POST/GET /api/v1/data/:object` (create + read only) and
* `GET /api/v1/discovery` / `/.well-known/objectstack`.
*
* Every one of these is DUPLICATE supply. `@objectstack/rest` serves full
* `/data` CRUD and, registering first, is what actually answers; the
* dispatcher and REST own discovery and this surface cedes it to them when
* either is present (#4018). The flag exists for a bare host that mounts
* neither.
* @deprecated On its way out (#4073) — opt in only during the deprecation
* window; the surface (and this flag) will be deleted after a release of
* observation, leaving this plugin a pure transport adapter (ADR-0076 D11).
*
* Every path it mounts is DUPLICATE supply, and lesser supply at that:
* C+R only, a subset of the gates, and a discovery payload that predates
* `DiscoverySchema`. `@objectstack/rest` serves full `/data` CRUD behind
* the whole gate stack, REST/the dispatcher own discovery (this surface
* cedes it to them when either is present, #4018), and a composed host
* answers byte-identically with this flag on or off (#4260). It exists
* only for a bare host that mounts neither — and the tax has been real:
* every platform invariant needed re-implementing here after the fact
* (#2567, #3298, #4018).
*
* The default is now `false`. A bare host that relied on it should mount
* `createRestApiPlugin` (`@objectstack/rest`) — it needs the same
* `objectql` this surface already required, and returns full CRUD plus
* the gates — or pass `true` explicitly until the deletion lands. A boot
* with no data/discovery provider at all logs a pointer instead of
* silently 404ing.
*
* It does NOT gate the current-user endpoints (`/auth/me/permissions`,
* `/auth/me/localization`, `/me/apps`) — this plugin is their only provider
* anywhere, so they register unconditionally (#4073).
*
* @default true
* @default false
*/
registerStandardEndpoints?: boolean;
/**
Expand Down Expand Up @@ -263,7 +278,9 @@ export class HonoServerPlugin implements Plugin {
constructor(options: HonoPluginOptions = {}) {
this.options = {
port: 3000,
registerStandardEndpoints: true,
// OFF by default (#4073): the convenience surface is deprecated
// duplicate supply. See the option's JSDoc for the migration path.
registerStandardEndpoints: false,
useApiRegistry: true,
spaFallback: false,
...options
Expand Down Expand Up @@ -620,6 +637,31 @@ export class HonoServerPlugin implements Plugin {
ctx.hook('kernel:ready', async () => {
this.registerDiscoveryAndCrudEndpoints(ctx);
});
} else {
// The default is OFF (#4073). For a composed host that is a no-op —
// REST/the dispatcher answer these routes byte-identically either
// way (#4260). The one composition it changes is a BARE host that
// mounts none of the three: it used to inherit the convenience
// surface implicitly and now gets 404s. Say so once at boot, with
// the remedy — the same honesty rule as #4018's discovery cede:
// absence must be loud, not something to diagnose from a silent
// 404. Checked on kernel:ready so every `kernel.use()` has landed,
// and quiet whenever a real API owner is mounted so transport-only
// compositions are not nagged.
ctx.hook('kernel:ready', async () => {
const kernel = ctx.getKernel() as { hasPlugin?(name: string): boolean } | undefined;
const hasPlugin = (name: string) =>
typeof kernel?.hasPlugin === 'function' && kernel.hasPlugin(name);
if (!hasPlugin(REST_API_PLUGIN) && !hasPlugin(RUNTIME_DISPATCHER_PLUGIN)) {
ctx.logger.warn(
'No data/discovery API is mounted on this server: `registerStandardEndpoints` '
+ 'defaults to false (#4073; the convenience surface is deprecated). Mount '
+ '`createRestApiPlugin` from @objectstack/rest (full CRUD + gates) or the '
+ 'runtime dispatcher — or pass `registerStandardEndpoints: true` to keep the '
+ 'legacy surface during the deprecation window.',
);
}
});
}

// Open the listening socket on kernel:listening — this fires
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #4073 — the DEFAULT of `registerStandardEndpoints` is `false`.
*
* The sibling suite (`hono-current-user-endpoints.test.ts`) pins the flag's two
* EXPLICIT positions. This one pins the position nobody writes down: a host
* that does not pass the option at all. Defaults are invisible at call sites —
* this issue's first correction was literally "I checked who passes the option,
* not who relies on the default" — so the default's behavior gets its own pins
* rather than riding on the explicit-`false` ones and hoping the constructor
* spread never drifts.
*
* Also pinned: the boot pointer. The flip is a no-op for a composed host
* (#4260: byte-identical answers with REST/the dispatcher mounted either way);
* the one composition it changes is a BARE host that mounted none of the three,
* which used to inherit `/data` + `/discovery` implicitly and now 404s. That
* absence must be LOUD — a warn naming the flag and the remedy — because a
* silent 404 is exactly the shape of failure this session of work kept paying
* for (`--with-ui` reusing a stale dist, CI green while `objectstack dev` sat
* dead). And it must stay QUIET when a real API owner is mounted, so
* transport-only compositions are not nagged into cargo-culting the flag back.
*/

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

const SURFACE_ROUTES = ['/api/v1/data/:object', '/api/v1/discovery', '/.well-known/objectstack'];
const ME_ROUTES = [
'/api/v1/auth/me/permissions',
'/api/v1/auth/me/localization',
'/api/v1/me/apps',
];

/**
* Boot through the real `init()`/`start()` and fire the registered
* `kernel:ready` hooks — mirrors the sibling suite, plus a warn recorder and a
* configurable `hasPlugin` so the composed/bare distinction is testable.
*/
async function boot(opts: {
pluginOptions?: ConstructorParameters<typeof HonoServerPlugin>[0];
installedPlugins?: string[];
}) {
const plugin = new HonoServerPlugin({ port: 0, cors: false, ...opts.pluginOptions });
const warns: string[] = [];
const readyHooks: Array<() => unknown> = [];
const ctx: any = {
logger: {
info() {}, debug() {}, error() {},
warn(msg: string) { warns.push(String(msg)); },
},
getKernel: () => ({
hasPlugin: (name: string) => (opts.installedPlugins ?? []).includes(name),
getService: () => undefined,
}),
registerService: () => {},
hook: (event: string, fn: () => unknown) => {
if (event === 'kernel:ready') readyHooks.push(fn);
},
getService: () => undefined,
};

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

return { app: (plugin as any).server.getRawApp(), warns };
}

const paths = (app: any): string[] => (app.routes ?? []).map((r: any) => r.path);

describe('registerStandardEndpoints defaults to OFF (#4073)', () => {
it('a host that does not pass the option gets no convenience surface — and keeps /me/*', async () => {
const { app } = await boot({});
const registered = paths(app);
for (const route of SURFACE_ROUTES) {
expect(registered, `${route} must NOT mount by default — the surface is opt-in now`)
.not.toContain(route);
}
// The flip must not take the unconditional endpoints with it.
for (const route of ME_ROUTES) expect(registered).toContain(route);
});

it('explicit opt-in still mounts the legacy surface during the deprecation window', async () => {
const { app } = await boot({ pluginOptions: { registerStandardEndpoints: true } });
const registered = paths(app);
for (const route of SURFACE_ROUTES) expect(registered).toContain(route);
});

it('a BARE boot says so loudly: one warn naming the flag and the remedy', async () => {
const { warns } = await boot({ installedPlugins: [] });
const pointer = warns.filter((w) => w.includes('registerStandardEndpoints'));
expect(pointer, 'a bare host must be told why /data and /discovery are absent').toHaveLength(1);
// The message must carry the remedy, not just the diagnosis.
expect(pointer[0]).toContain('@objectstack/rest');
expect(pointer[0]).toContain('#4073');
});

it('a COMPOSED boot stays quiet — REST owns the routes, nothing is missing', async () => {
const { warns } = await boot({ installedPlugins: ['com.objectstack.rest.api'] });
expect(warns.filter((w) => w.includes('registerStandardEndpoints'))).toHaveLength(0);
});

it('a dispatcher-composed boot stays quiet too', async () => {
const { warns } = await boot({ installedPlugins: ['com.objectstack.runtime.dispatcher'] });
expect(warns.filter((w) => w.includes('registerStandardEndpoints'))).toHaveLength(0);
});

it('opting in silences the pointer — the legacy surface IS the data API then', async () => {
const { warns } = await boot({
pluginOptions: { registerStandardEndpoints: true },
installedPlugins: [],
});
expect(warns.filter((w) => w.includes('registerStandardEndpoints'))).toHaveLength(0);
});
});
Loading