Skip to content

Commit 623e555

Browse files
authored
feat(plugin-hono-server): registerStandardEndpoints defaults to false — the convenience surface is opt-in (#4073) (#4280)
Step 1 of #4073's retirement, executed as decided: the deprecated convenience surface — raw C+R `/api/v1/data/:object`, `/api/v1/discovery`, `/.well-known/objectstack` — is opt-in, no longer the default. Everything the flag mounts is duplicate and LESSER supply (C+R only, a subset of the gates, a pre-DiscoverySchema payload); 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 tax was real: #2567, #3298 and #4018 each re-implemented a platform invariant here after the fact. Impact was checked against who RELIES ON THE DEFAULT, not who passes the option (the issue's first correction): `os serve` and `objectstack dev` are composed — no change; the in-repo default-reliant boots never touch the surface; the three suites that test the legacy surface itself now opt in explicitly; `/me/*` was never behind the flag (#4144). Bare hosts — none in-repo — get ONE boot warn naming the flag, #4073 and both remedies (mount `createRestApiPlugin`, or opt in during the deprecation window), and the warn stays quiet whenever REST or the dispatcher is mounted. Absence is loud; composed hosts are not nagged. The default gets its own six-pin suite (`hono-standard-endpoints-default.test.ts`) because defaults are invisible at call sites — a constructor-spread drift would otherwise regress silently. The docs drift check surfaced one REAL drift: `authentication.mdx`'s minimal stack is exactly the bare-host shape and its own page fetches `/api/v1/data/task`; the example now mounts `createRestApiPlugin`, the migration the changeset prescribes, rather than teaching readers a deprecated flag. Verified: plugin-hono-server 155/155, runtime hono suites 23/23, client hono suites 16/16, http-conformance 46/46, package tsc clean, changed-file lint 0. Next per #4073: one release of observation, then `registerDiscoveryAndCrudEndpoints`, the flag and the dead priority constants are deleted and the plugin becomes a pure transport adapter (ADR-0076 D11).
1 parent 978fd7d commit 623e555

6 files changed

Lines changed: 218 additions & 9 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/plugin-hono-server": minor
3+
---
4+
5+
feat(plugin-hono-server): `registerStandardEndpoints` now defaults to `false` — the deprecated CRUD/discovery convenience surface is opt-in (#4073)
6+
7+
The flag mounts raw C+R `/api/v1/data/:object` and `/api/v1/discovery` /
8+
`/.well-known/objectstack`. Every path it mounts is duplicate — and lesser —
9+
supply: C+R only, a subset of the gates, a pre-`DiscoverySchema` discovery
10+
payload. `@objectstack/rest` serves full `/data` CRUD behind the whole gate
11+
stack, REST/the dispatcher own discovery (#4018 cede), and #4260 pinned that a
12+
composed host answers **byte-identically** with the flag on or off. The surface
13+
has also been a standing tax: #2567, #3298 and #4018 each had to re-implement a
14+
platform invariant here after the fact.
15+
16+
**FROM → TO**
17+
18+
- **Composed hosts (REST and/or the dispatcher mounted)**`os serve`,
19+
`objectstack dev`, cloud's objectos, every documented path: **no change**.
20+
Those plugins already answer every route this surface covered, and answered
21+
them first.
22+
- **Bare hosts (HonoServerPlugin only)**: `/api/v1/data/:object`,
23+
`/api/v1/discovery` and `/.well-known/objectstack` are **no longer mounted by
24+
default**. The boot now logs a warn naming the flag and the remedy instead of
25+
leaving a silent 404. Migrate by mounting `createRestApiPlugin` from
26+
`@objectstack/rest` — it needs the same `objectql` service this surface
27+
already required, and returns full CRUD plus the gate stack — or pass
28+
`registerStandardEndpoints: true` to keep the legacy surface during the
29+
deprecation window.
30+
- The current-user endpoints (`/auth/me/permissions`, `/auth/me/localization`,
31+
`/me/apps`) are **unaffected** — they never sat behind this flag (#4144) and
32+
register unconditionally.
33+
34+
The flag is now marked `@deprecated`. Next step per #4073: one release of
35+
observation, then `registerDiscoveryAndCrudEndpoints` (and the flag) are deleted
36+
and this plugin becomes a pure transport adapter (ADR-0076 D11).

content/docs/permissions/authentication.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ import { DriverPlugin } from '@objectstack/runtime';
128128
import { InMemoryDriver } from '@objectstack/driver-memory';
129129
import { AuthPlugin } from '@objectstack/plugin-auth';
130130
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
131+
import { createRestApiPlugin } from '@objectstack/rest';
131132

132133
const kernel = new ObjectKernel();
133134

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

145+
// Data API — serves /api/v1/data/:object with full CRUD behind the gate
146+
// stack. Without it this minimal stack has no data routes: the HTTP server
147+
// plugin is a transport adapter and its legacy built-in data surface is
148+
// deprecated and off by default (#4073). The authenticated fetch further
149+
// down this page reads /api/v1/data/task through this plugin.
150+
await kernel.use(createRestApiPlugin());
151+
144152
// Authentication plugin
145153
await kernel.use(new AuthPlugin({
146154
secret: process.env.OS_AUTH_SECRET,

packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@ function bootStandardEndpoints(opts: {
2020
restConfig?: { api?: { requireAuth?: boolean } };
2121
services: Record<string, unknown>;
2222
}) {
23-
const plugin = new HonoServerPlugin({ port: 0, restConfig: opts.restConfig as any });
23+
// Explicit opt-in: the raw surface whose #2567 gate is under test defaults
24+
// OFF now (#4073).
25+
const plugin = new HonoServerPlugin({
26+
port: 0,
27+
registerStandardEndpoints: true,
28+
restConfig: opts.restConfig as any,
29+
});
2430
const ctx: any = {
2531
logger: { info() {}, debug() {}, warn() {}, error() {} },
2632
getKernel: () => ({ getService: (n: string) => opts.services[n] }),

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ const RUNTIME_DISPATCHER_PLUGIN = 'com.objectstack.runtime.dispatcher';
2525
* which is how this surface decides whether a real discovery owner is present.
2626
*/
2727
function bootStandardEndpoints(installedPlugins: string[] = []) {
28-
const plugin = new HonoServerPlugin({ port: 0 });
28+
// Explicit opt-in: the legacy surface under test defaults OFF now (#4073).
29+
const plugin = new HonoServerPlugin({ port: 0, registerStandardEndpoints: true });
2930
const ctx: any = {
3031
logger: { info() {}, debug() {}, warn() {}, error() {} },
3132
getKernel: () => ({

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

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,32 @@ export interface HonoPluginOptions {
5252
* raw `POST/GET /api/v1/data/:object` (create + read only) and
5353
* `GET /api/v1/discovery` / `/.well-known/objectstack`.
5454
*
55-
* Every one of these is DUPLICATE supply. `@objectstack/rest` serves full
56-
* `/data` CRUD and, registering first, is what actually answers; the
57-
* dispatcher and REST own discovery and this surface cedes it to them when
58-
* either is present (#4018). The flag exists for a bare host that mounts
59-
* neither.
55+
* @deprecated On its way out (#4073) — opt in only during the deprecation
56+
* window; the surface (and this flag) will be deleted after a release of
57+
* observation, leaving this plugin a pure transport adapter (ADR-0076 D11).
58+
*
59+
* Every path it mounts is DUPLICATE supply, and lesser supply at that:
60+
* C+R only, a subset of the gates, and a discovery payload that predates
61+
* `DiscoverySchema`. `@objectstack/rest` serves full `/data` CRUD behind
62+
* the whole gate stack, REST/the dispatcher own discovery (this surface
63+
* cedes it to them when either is present, #4018), and a composed host
64+
* answers byte-identically with this flag on or off (#4260). It exists
65+
* only for a bare host that mounts neither — and the tax has been real:
66+
* every platform invariant needed re-implementing here after the fact
67+
* (#2567, #3298, #4018).
68+
*
69+
* The default is now `false`. A bare host that relied on it should mount
70+
* `createRestApiPlugin` (`@objectstack/rest`) — it needs the same
71+
* `objectql` this surface already required, and returns full CRUD plus
72+
* the gates — or pass `true` explicitly until the deletion lands. A boot
73+
* with no data/discovery provider at all logs a pointer instead of
74+
* silently 404ing.
6075
*
6176
* It does NOT gate the current-user endpoints (`/auth/me/permissions`,
6277
* `/auth/me/localization`, `/me/apps`) — this plugin is their only provider
6378
* anywhere, so they register unconditionally (#4073).
6479
*
65-
* @default true
80+
* @default false
6681
*/
6782
registerStandardEndpoints?: boolean;
6883
/**
@@ -263,7 +278,9 @@ export class HonoServerPlugin implements Plugin {
263278
constructor(options: HonoPluginOptions = {}) {
264279
this.options = {
265280
port: 3000,
266-
registerStandardEndpoints: true,
281+
// OFF by default (#4073): the convenience surface is deprecated
282+
// duplicate supply. See the option's JSDoc for the migration path.
283+
registerStandardEndpoints: false,
267284
useApiRegistry: true,
268285
spaFallback: false,
269286
...options
@@ -620,6 +637,31 @@ export class HonoServerPlugin implements Plugin {
620637
ctx.hook('kernel:ready', async () => {
621638
this.registerDiscoveryAndCrudEndpoints(ctx);
622639
});
640+
} else {
641+
// The default is OFF (#4073). For a composed host that is a no-op —
642+
// REST/the dispatcher answer these routes byte-identically either
643+
// way (#4260). The one composition it changes is a BARE host that
644+
// mounts none of the three: it used to inherit the convenience
645+
// surface implicitly and now gets 404s. Say so once at boot, with
646+
// the remedy — the same honesty rule as #4018's discovery cede:
647+
// absence must be loud, not something to diagnose from a silent
648+
// 404. Checked on kernel:ready so every `kernel.use()` has landed,
649+
// and quiet whenever a real API owner is mounted so transport-only
650+
// compositions are not nagged.
651+
ctx.hook('kernel:ready', async () => {
652+
const kernel = ctx.getKernel() as { hasPlugin?(name: string): boolean } | undefined;
653+
const hasPlugin = (name: string) =>
654+
typeof kernel?.hasPlugin === 'function' && kernel.hasPlugin(name);
655+
if (!hasPlugin(REST_API_PLUGIN) && !hasPlugin(RUNTIME_DISPATCHER_PLUGIN)) {
656+
ctx.logger.warn(
657+
'No data/discovery API is mounted on this server: `registerStandardEndpoints` '
658+
+ 'defaults to false (#4073; the convenience surface is deprecated). Mount '
659+
+ '`createRestApiPlugin` from @objectstack/rest (full CRUD + gates) or the '
660+
+ 'runtime dispatcher — or pass `registerStandardEndpoints: true` to keep the '
661+
+ 'legacy surface during the deprecation window.',
662+
);
663+
}
664+
});
623665
}
624666

625667
// Open the listening socket on kernel:listening — this fires
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4073 — the DEFAULT of `registerStandardEndpoints` is `false`.
5+
*
6+
* The sibling suite (`hono-current-user-endpoints.test.ts`) pins the flag's two
7+
* EXPLICIT positions. This one pins the position nobody writes down: a host
8+
* that does not pass the option at all. Defaults are invisible at call sites —
9+
* this issue's first correction was literally "I checked who passes the option,
10+
* not who relies on the default" — so the default's behavior gets its own pins
11+
* rather than riding on the explicit-`false` ones and hoping the constructor
12+
* spread never drifts.
13+
*
14+
* Also pinned: the boot pointer. The flip is a no-op for a composed host
15+
* (#4260: byte-identical answers with REST/the dispatcher mounted either way);
16+
* the one composition it changes is a BARE host that mounted none of the three,
17+
* which used to inherit `/data` + `/discovery` implicitly and now 404s. That
18+
* absence must be LOUD — a warn naming the flag and the remedy — because a
19+
* silent 404 is exactly the shape of failure this session of work kept paying
20+
* for (`--with-ui` reusing a stale dist, CI green while `objectstack dev` sat
21+
* dead). And it must stay QUIET when a real API owner is mounted, so
22+
* transport-only compositions are not nagged into cargo-culting the flag back.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import { HonoServerPlugin } from './hono-plugin';
27+
28+
const SURFACE_ROUTES = ['/api/v1/data/:object', '/api/v1/discovery', '/.well-known/objectstack'];
29+
const ME_ROUTES = [
30+
'/api/v1/auth/me/permissions',
31+
'/api/v1/auth/me/localization',
32+
'/api/v1/me/apps',
33+
];
34+
35+
/**
36+
* Boot through the real `init()`/`start()` and fire the registered
37+
* `kernel:ready` hooks — mirrors the sibling suite, plus a warn recorder and a
38+
* configurable `hasPlugin` so the composed/bare distinction is testable.
39+
*/
40+
async function boot(opts: {
41+
pluginOptions?: ConstructorParameters<typeof HonoServerPlugin>[0];
42+
installedPlugins?: string[];
43+
}) {
44+
const plugin = new HonoServerPlugin({ port: 0, cors: false, ...opts.pluginOptions });
45+
const warns: string[] = [];
46+
const readyHooks: Array<() => unknown> = [];
47+
const ctx: any = {
48+
logger: {
49+
info() {}, debug() {}, error() {},
50+
warn(msg: string) { warns.push(String(msg)); },
51+
},
52+
getKernel: () => ({
53+
hasPlugin: (name: string) => (opts.installedPlugins ?? []).includes(name),
54+
getService: () => undefined,
55+
}),
56+
registerService: () => {},
57+
hook: (event: string, fn: () => unknown) => {
58+
if (event === 'kernel:ready') readyHooks.push(fn);
59+
},
60+
getService: () => undefined,
61+
};
62+
63+
await plugin.init(ctx);
64+
await plugin.start(ctx);
65+
for (const fn of readyHooks) await fn();
66+
67+
return { app: (plugin as any).server.getRawApp(), warns };
68+
}
69+
70+
const paths = (app: any): string[] => (app.routes ?? []).map((r: any) => r.path);
71+
72+
describe('registerStandardEndpoints defaults to OFF (#4073)', () => {
73+
it('a host that does not pass the option gets no convenience surface — and keeps /me/*', async () => {
74+
const { app } = await boot({});
75+
const registered = paths(app);
76+
for (const route of SURFACE_ROUTES) {
77+
expect(registered, `${route} must NOT mount by default — the surface is opt-in now`)
78+
.not.toContain(route);
79+
}
80+
// The flip must not take the unconditional endpoints with it.
81+
for (const route of ME_ROUTES) expect(registered).toContain(route);
82+
});
83+
84+
it('explicit opt-in still mounts the legacy surface during the deprecation window', async () => {
85+
const { app } = await boot({ pluginOptions: { registerStandardEndpoints: true } });
86+
const registered = paths(app);
87+
for (const route of SURFACE_ROUTES) expect(registered).toContain(route);
88+
});
89+
90+
it('a BARE boot says so loudly: one warn naming the flag and the remedy', async () => {
91+
const { warns } = await boot({ installedPlugins: [] });
92+
const pointer = warns.filter((w) => w.includes('registerStandardEndpoints'));
93+
expect(pointer, 'a bare host must be told why /data and /discovery are absent').toHaveLength(1);
94+
// The message must carry the remedy, not just the diagnosis.
95+
expect(pointer[0]).toContain('@objectstack/rest');
96+
expect(pointer[0]).toContain('#4073');
97+
});
98+
99+
it('a COMPOSED boot stays quiet — REST owns the routes, nothing is missing', async () => {
100+
const { warns } = await boot({ installedPlugins: ['com.objectstack.rest.api'] });
101+
expect(warns.filter((w) => w.includes('registerStandardEndpoints'))).toHaveLength(0);
102+
});
103+
104+
it('a dispatcher-composed boot stays quiet too', async () => {
105+
const { warns } = await boot({ installedPlugins: ['com.objectstack.runtime.dispatcher'] });
106+
expect(warns.filter((w) => w.includes('registerStandardEndpoints'))).toHaveLength(0);
107+
});
108+
109+
it('opting in silences the pointer — the legacy surface IS the data API then', async () => {
110+
const { warns } = await boot({
111+
pluginOptions: { registerStandardEndpoints: true },
112+
installedPlugins: [],
113+
});
114+
expect(warns.filter((w) => w.includes('registerStandardEndpoints'))).toHaveLength(0);
115+
});
116+
});

0 commit comments

Comments
 (0)