From ee092cf77baa412b87224177645f39eebba14b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 04:08:42 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(plugin-hono-server)!:=20delete=20the?= =?UTF-8?q?=20CRUD/discovery=20surface=20=E2=80=94=20the=20plugin=20is=20a?= =?UTF-8?q?=20transport=20adapter=20(#4073)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the retirement. HonoServerPlugin now owns the socket, the middleware and the three current-user endpoints, and nothing else. The data and discovery APIs have one owner each — @objectstack/rest and the runtime dispatcher (ADR-0076 D11). Deleted: raw C+R `/api/v1/data/:object` (+ `/:id`), this plugin's third `/api/v1/discovery` + `/.well-known/objectstack` payload, and the `registerStandardEndpoints` flag itself — gone, not defaulted off. With it go `DISCOVERY_ROUTE_SEGMENTS` and six imports (`IDataEngine`, `shouldDenyAnonymous`, both `ANONYMOUS_DENY_*`, `ApiRoutes`, `makeExecutionContextResolver`) that existed only to serve it. Unaffected: `/auth/me/*` + `/me/apps` (sole supply, unconditional since #4144), and every composed host — `os serve`, `objectstack dev`, cloud's objectos — which mount an owner that already answered these routes byte-identically either way (#4260). A host with NO owner keeps the boot warn, reworded to name both remedies now that opting in is not one of them. Tests, each decided on what it was actually pinning rather than swept: - `hono-discovery` / `hono-anonymous-deny` — deleted. Both existed wholly to test the retired routes. #2567's invariant is not lost with the latter: the gate lives in core and REST enforces it on the surface that survives. - `server-timing-e2e` — KEPT, repointed at `/auth/me/permissions`. #3361's regression value is "perf middleware + a handler that resolves the principal itself", not the route; the permission-set fixtures already fit. Its anonymous case asserts 200-without-timings instead of 401, because that endpoint answers anonymous callers rather than denying them — the invariant is the closed gate. - `client.hono` — KEPT, repointed at a REST-composed host. It is the ONLY coverage of `client.connect()` and the discovery handshake, so deleting it would have cost real signal; its #4018 assertions now read against REST's registry-driven discovery, where `metadata` IS advertised and answers. - `hono-standard-endpoints-default` → `hono-transport-only`. Its subject was the flag's default; it now pins the end state, including that passing the removed option resurrects nothing. - The two cases pinning `/me/*` ordering relative to the CRUD block are gone, with a note: #4088 made plugin-auth's catch-all fall through, so the ordering fragility they guarded no longer exists and is pinned directly in `auth-catchall-fallthrough.test.ts`. - Seven cross-package call sites simply drop the option. AGENTS.md gains the four rules this thread paid for — one route one owner, explicit composition over default magic, absence must be loud, machine-readable surfaces must not lie — plus the verification note: "who serves this path" is a question about the composed, PROVISIONED runtime. #4073 was answered wrongly three times, once per shortcut around that. Verified: plugin-hono-server 135/135, client 3 suites 9/9, runtime 4 integration suites 19/19, http-conformance 46/46, full build 71/71, package tsc clean, changed-file lint 0/0. Net −678 lines. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gEeLZ4oTeSXG6fKG1r3vd --- .changeset/hono-retire-standard-endpoints.md | 40 ++ AGENTS.md | 41 ++ .../src/client.batch-transaction.test.ts | 1 - .../src/client.environment-scoping.test.ts | 1 - packages/client/src/client.hono.test.ts | 45 ++- .../src/current-user-endpoints.ts | 46 +-- .../src/hono-anonymous-deny.test.ts | 103 ----- .../src/hono-current-user-endpoints.test.ts | 85 ++--- .../src/hono-discovery.test.ts | 167 -------- .../src/hono-plugin.test.ts | 27 -- .../plugin-hono-server/src/hono-plugin.ts | 361 ++---------------- .../hono-standard-endpoints-default.test.ts | 116 ------ .../src/hono-transport-only.test.ts | 102 +++++ .../src/server-timing-e2e.test.ts | 44 ++- .../src/conformance.integration.test.ts | 2 +- ...ispatcher-plugin.ready.integration.test.ts | 2 +- .../notifications.hono.integration.test.ts | 2 +- .../src/route-parity.integration.test.ts | 2 +- ...d-endpoints-precedence.integration.test.ts | 153 -------- 19 files changed, 331 insertions(+), 1009 deletions(-) create mode 100644 .changeset/hono-retire-standard-endpoints.md delete mode 100644 packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts delete mode 100644 packages/plugins/plugin-hono-server/src/hono-discovery.test.ts delete mode 100644 packages/plugins/plugin-hono-server/src/hono-standard-endpoints-default.test.ts create mode 100644 packages/plugins/plugin-hono-server/src/hono-transport-only.test.ts delete mode 100644 packages/runtime/src/standard-endpoints-precedence.integration.test.ts diff --git a/.changeset/hono-retire-standard-endpoints.md b/.changeset/hono-retire-standard-endpoints.md new file mode 100644 index 0000000000..5a197400ed --- /dev/null +++ b/.changeset/hono-retire-standard-endpoints.md @@ -0,0 +1,40 @@ +--- +"@objectstack/plugin-hono-server": major +--- + +feat(plugin-hono-server)!: delete the CRUD/discovery convenience surface and the `registerStandardEndpoints` flag — the plugin is a transport adapter (#4073) + +Completes the retirement. `HonoServerPlugin` now owns the socket, the middleware +and the three current-user endpoints, and nothing else. The data and discovery +APIs have one owner each: `@objectstack/rest` and the runtime dispatcher +(ADR-0076 D11). + +**Removed** + +- `POST/GET /api/v1/data/:object` and `GET /api/v1/data/:object/:id` — the raw + C+R surface that delegated straight to ObjectQL. +- `GET /api/v1/discovery` and `GET /.well-known/objectstack` — this plugin's + third discovery payload, which predated `DiscoverySchema` and could not + satisfy it (no `services`, the ADR-0076 D12 source of truth). +- The `registerStandardEndpoints` option. It is gone, not defaulted off: passing + it is now a type error, and passing it via `as never` mounts nothing. + +**Unaffected** + +- `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` — this plugin + is the platform's only supply and they register unconditionally (#4144). +- Every composed host: `os serve`, `objectstack dev`, cloud's objectos and every + documented composition mount REST and/or the dispatcher, which already served + these routes and answered byte-identically with the flag on or off (#4260). + +**Migration** — only a host that mounts `HonoServerPlugin` with neither owner is +affected. It now has no data or discovery API, and the boot warns once naming +both remedies. Mount `createRestApiPlugin` from `@objectstack/rest` for full +CRUD behind the gate stack, or `createDispatcherPlugin` from +`@objectstack/runtime`. There is no flag to opt back in. + +**Why** — the surface was duplicate and lesser supply (C+R only, a subset of the +gates, a non-conforming discovery payload), and it charged rent: #2567, #3298 +and #4018 each had to re-implement a platform invariant on it after the fact, +because a second implementation of a route is a second place every future +invariant must be remembered. diff --git a/AGENTS.md b/AGENTS.md index 9ca598b805..99c52b9316 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -363,6 +363,47 @@ export class MyPlugin implements Plugin { --- +## Route & surface ownership + +Four rules, each paid for by a real bug. They matter more than usual here because +this repo is largely written by agents, and every one of them is a trap that +reads as reasonable code. + +**1. One route, one owner.** Never add a second implementation of a path that +another package already serves, however convenient. A shadowed duplicate is code +that `grep` finds and the runtime never runs — the exact input that makes an +agent (or a human) reason confidently from dead code. It also silently forks +every future invariant: the retired hono `/data` surface had to re-learn the +anonymous-deny gate (#2567), honest batch capability reporting (#3298) and +discovery accuracy (#4018), each after the fact, each because someone fixed the +real owner and never knew about the copy. Retired in #4073. + +**2. Explicit composition over default magic.** A capability that appears +because of a default nobody wrote down is invisible at every call site — and +call sites are the primary evidence an agent reasons from. #4073's own first +analysis checked *who passes the option* and missed *who relies on the default*; +the correction is in that issue's opening paragraph. If a host should get a +surface, it should mount it. + +**3. Absence must be loud.** A composition that legitimately serves nothing +should say so once at boot, naming the remedy — never leave a bare 404 to be +diagnosed. The same rule applies to tooling: a verifier that silently degrades +(reusing a stale build, skipping a check it could not run) is worse than no +verifier, because it reports success. Prefer failing to falling back. + +**4. Machine-readable surfaces must not lie.** `/discovery` and friends are read +by SDKs, codegen and AI clients. Advertise only what is actually mounted, and +mount everything advertised (ADR-0076 D12) — a wrong answer here propagates into +everything built on top of it. + +**Verifying any of this:** "who serves this path" is a question about the +composed, *provisioned* runtime — not about which plugin declares it, not about +registration order, and not about a minimal harness that merely boots. #4073 was +answered wrongly three times, once per each of those shortcuts. Boot the real +composition with its real services, or do not claim an answer. + +--- + ## Post-Task Checklist 1. `pnpm test` — verify nothing broke. diff --git a/packages/client/src/client.batch-transaction.test.ts b/packages/client/src/client.batch-transaction.test.ts index 00a3ab76f5..c92d2f3a07 100644 --- a/packages/client/src/client.batch-transaction.test.ts +++ b/packages/client/src/client.batch-transaction.test.ts @@ -59,7 +59,6 @@ describe('data.batchTransaction (live Hono, #1604)', () => { port: 0, // Skip hardcoded hono CRUD routes so createRestApiPlugin owns // route registration (including the root /batch route). - registerStandardEndpoints: false, }), ); diff --git a/packages/client/src/client.environment-scoping.test.ts b/packages/client/src/client.environment-scoping.test.ts index c44193f500..f28ba46c6a 100644 --- a/packages/client/src/client.environment-scoping.test.ts +++ b/packages/client/src/client.environment-scoping.test.ts @@ -44,7 +44,6 @@ describe('Project-scoped REST routing (live Hono)', () => { port: 0, // IMPORTANT: skip hardcoded hono CRUD routes so createRestApiPlugin // owns /data and /meta registration end-to-end. - registerStandardEndpoints: false, }); kernel.use(honoPlugin); diff --git a/packages/client/src/client.hono.test.ts b/packages/client/src/client.hono.test.ts index 2a467567d1..5c5f26e863 100644 --- a/packages/client/src/client.hono.test.ts +++ b/packages/client/src/client.hono.test.ts @@ -3,6 +3,7 @@ import { LiteKernel } from '@objectstack/core'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; describe('ObjectStackClient (with Hono Server)', () => { @@ -25,19 +26,22 @@ describe('ObjectStackClient (with Hono Server)', () => { }, } as any); - // 2. Setup Hono Plugin - // This suite exercises the CLIENT's data operations over the raw-hono - // standard endpoints; it wires no auth service. Opt out of the - // secure-by-default anonymous-deny posture (#2567) so the anonymous - // data CRUD under test stays reachable — the same explicit - // `requireAuth: false` a deployment that intentionally serves data - // publicly would set. The gate itself is proven in - // plugin-hono-server/hono-anonymous-deny.test.ts. - const honoPlugin = new HonoServerPlugin({ - port: 0, - registerStandardEndpoints: true, - }); + // 2. Setup Hono Plugin — transport only. + // + // This suite used to run against the raw-hono standard endpoints. That + // surface is gone (#4073): the plugin serves the socket and the + // current-user endpoints, and the data + discovery APIs belong to their + // owner. What is under test here is the CLIENT — `connect()`, the + // discovery handshake, and CRUD over HTTP — so it now runs against the + // real owner, which is also what every deployment actually serves. + const honoPlugin = new HonoServerPlugin({ port: 0 }); kernel.use(honoPlugin); + + // `requireAuth: false` keeps the anonymous CRUD under test reachable — + // the same explicit opt-out a deployment intentionally serving public + // data would set (#2567/#3963); the gate itself is proven in + // @objectstack/rest's own suites. + kernel.use(createRestApiPlugin({ api: { api: { requireAuth: false } as any } })); // --- BROKER SHIM START --- // HttpDispatcher requires a broker to function. We inject a simple shim. @@ -152,19 +156,14 @@ describe('ObjectStackClient (with Hono Server)', () => { // Client should have populated discovery info expect(client['discoveryInfo']).toBeDefined(); - // The standalone hono surface advertises what it actually mounts - // (#4018): /data CRUD and the /auth/me/* helpers are registered here, - // so both are advertised and both answer. + // Discovery is REST's, computed from its registry (#4018 D12: declared + // === enforced). Every route it advertises must actually answer. const endpoints = client['discoveryInfo']!.routes; expect(endpoints.data).toContain('/api/v1/data'); - expect(endpoints.auth).toContain('/api/v1/auth'); - - // `metadata` is NOT advertised on this boot, and that is the point of - // #4018: no plugin here mounts /api/v1/meta (it ships with - // @objectstack/rest / the dispatcher), so the old hardcoded table was - // promising a route that 404s. Proof the omission is honest: - expect(endpoints.metadata).toBeUndefined(); - expect((await fetch(`${baseUrl}/api/v1/meta/objects`)).status).toBe(404); + expect(endpoints.metadata).toContain('/api/v1/meta'); + + // Enforced, not just declared — the pairing #4018 exists to hold. + expect((await fetch(`${baseUrl}/api/v1/meta/objects`)).status).not.toBe(404); }); it('should create and retrieve data via hono', async () => { diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index cd2242c535..94a58ed672 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -13,9 +13,10 @@ * `/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. #4079 lifted them out from under - * `registerStandardEndpoints` (which covers only DUPLICATE supply — raw `/data` - * CRUD and a discovery the dispatcher/REST own) so the flag could not take the - * console down with it. + * `registerStandardEndpoints`, which also gated a DUPLICATE `/data` CRUD + + * discovery surface, so that flag could not take the console down with it. That + * surface and the flag have since been deleted (#4073); these three are all the + * plugin serves beyond the socket. * * That split fixed the flag but left the supply still welded to * {@link HonoServerPlugin}: a host that stands up a bare {@link HonoHttpServer} @@ -405,13 +406,14 @@ export function annotateEffectiveApiOperations( } /** - * Build the session → `ExecutionContext` resolver the current-user endpoints — - * and the plugin's standalone `/data` CRUD surface — both need. + * Build the session → `ExecutionContext` resolver the current-user endpoints + * need. * - * Extracted from `registerDiscoveryAndCrudEndpoints` when the current-user - * endpoints stopped being gated on `registerStandardEndpoints` (#4073): they - * resolve the same principal the `/data` routes do, and one resolver is the - * only way the two groups can agree on who the caller is. + * Extracted from `registerDiscoveryAndCrudEndpoints` when these endpoints + * stopped being gated on `registerStandardEndpoints` (#4073), so the two groups + * would agree on who the caller is. That surface has since been deleted and + * these endpoints are the only remaining caller — kept as a named export + * because the serverless host path (cloud#924) composes it directly. */ export function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext) { const getObjectQL = () => ctx.getService('objectql'); @@ -600,21 +602,19 @@ export function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext) { * Register the current-user endpoints — `/auth/me/permissions`, * `/auth/me/localization` and `/me/apps` — on `rawApp`. * - * When {@link HonoServerPlugin} drives this, it is UNCONDITIONAL, unlike the - * plugin's CRUD + discovery block (#4073). Those two groups used to ride on one - * `registerStandardEndpoints` flag, which conflated two opposite things. The flag - * covers DUPLICATE supply — raw `/data` CRUD that `@objectstack/rest` also serves - * (and, being registered first, really serves), plus a discovery that the - * dispatcher/REST own (#4018). These three are the opposite: nothing else in the - * platform mounts them. `packages/rest` and `packages/runtime` register no - * `/me/*` route at all, the console reads `/auth/me/permissions` for its whole - * permission layer and `/auth/me/localization` for regional defaults, and + * When {@link HonoServerPlugin} drives this, it is UNCONDITIONAL — and these are + * now the only routes it mounts beyond the socket itself. + * + * They used to ride on the `registerStandardEndpoints` flag alongside a raw + * `/data` CRUD + discovery surface, one flag over two opposite things (#4073). + * That surface was DUPLICATE supply and has been deleted; these three are the + * opposite and could not be. Nothing else in the platform mounts them: + * `packages/rest` and `packages/runtime` register no `/me/*` route at all, the + * console reads `/auth/me/permissions` for its whole permission layer and + * `/auth/me/localization` for regional defaults, and * `core/security/auth-gate.ts` allow-lists `/me/apps` + `/me/localization` as - * endpoints a gated user MUST still reach to bootstrap the remediation UI. - * `os serve` gets them only because `registerStandardEndpoints` defaults to true - * (`cli/src/commands/serve.ts` passes just `{ port }`), so turning that flag off - * — or retiring the convenience surface it names — would have taken the console - * down with it. + * endpoints a gated user MUST still reach to bootstrap the remediation UI. The + * split (#4144) had to land before the deletion for exactly that reason. * * IDEMPOTENT: returns `false` and registers nothing when all three paths are * already served. That is what lets a host call this eagerly on its own raw app diff --git a/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts b/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts deleted file mode 100644 index a32efcafca..0000000000 --- a/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// #2567 — the raw-hono standard `/data` endpoints must honour the same -// secure-by-default (`requireAuth`) anonymous-deny posture as the REST `/data` -// routes. Before the gate, these routes delegated straight to ObjectQL and were -// only *shadowed* when the REST plugin registered the same paths first — so the -// anonymous posture depended on plugin registration order. These tests drive the -// real routes on a real Hono app (no REST plugin in the picture) to prove the -// gate stands on its own. - -import { describe, it, expect } from 'vitest'; -import { HonoServerPlugin } from './hono-plugin'; - -/** - * Boot a plugin and register ONLY the standard CRUD routes on its real Hono - * app, then hand back the app so tests can drive HTTP requests directly. No - * listening socket, no CORS/static — we exercise the data routes in isolation. - */ -function bootStandardEndpoints(opts: { - restConfig?: { api?: { requireAuth?: boolean } }; - services: Record; -}) { - // 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] }), - registerService: () => {}, - hook: () => {}, - getService: (n: string) => { - const s = opts.services[n]; - if (s === undefined) throw new Error(`no service: ${n}`); - return s; - }, - }; - (plugin as any).registerDiscoveryAndCrudEndpoints(ctx); - return (plugin as any).server.getRawApp(); -} - -// ObjectQL stub — every read returns empty, every write echoes an id. Enough -// for the routes to reach a 200 once the gate has been cleared. -const objectql = { - find: async () => [], - insert: async () => ({ id: 'new-id' }), -}; - -// Auth stub — resolves a session ONLY when the request carries `x-test-user`, -// so the same boot serves both anonymous and authenticated requests. -const auth = { - api: { - getSession: async ({ headers }: { headers: Headers }) => { - const uid = headers?.get?.('x-test-user'); - return uid ? { user: { id: uid }, session: {} } : null; - }, - }, -}; - -const REQ = 'http://localhost/api/v1/data/thing'; - -describe('raw-hono /data — anonymous-deny gate (#2567)', () => { - it('secure-by-default (no restConfig): anonymous LIST is 401', async () => { - const app = bootStandardEndpoints({ services: { objectql, auth } }); - const res = await app.request(REQ, { method: 'GET' }); - expect(res.status).toBe(401); - }); - - it('secure-by-default: anonymous GET-by-id is 401', async () => { - const app = bootStandardEndpoints({ services: { objectql, auth } }); - const res = await app.request(`${REQ}/abc`, { method: 'GET' }); - expect(res.status).toBe(401); - }); - - it('secure-by-default: anonymous CREATE is 401', async () => { - const app = bootStandardEndpoints({ services: { objectql, auth } }); - const res = await app.request(REQ, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: 'x' }), - }); - expect(res.status).toBe(401); - }); - - it('an AUTHENTICATED caller is served (deny targets anonymity, not the route)', async () => { - const app = bootStandardEndpoints({ services: { objectql, auth } }); - const res = await app.request(REQ, { method: 'GET', headers: { 'x-test-user': 'u1' } }); - expect(res.status).toBe(200); - }); - - it('there is no opt-out — an anonymous read is 401 regardless of config (#3963)', async () => { - // `api.requireAuth: false` is retired: it no longer opens the surface. - const app = bootStandardEndpoints({ - restConfig: { api: { requireAuth: false } as any }, - services: { objectql, auth }, - }); - const res = await app.request(REQ, { method: 'GET' }); - expect(res.status).toBe(401); - }); -}); diff --git a/packages/plugins/plugin-hono-server/src/hono-current-user-endpoints.test.ts b/packages/plugins/plugin-hono-server/src/hono-current-user-endpoints.test.ts index 81d855593f..6c9b292388 100644 --- a/packages/plugins/plugin-hono-server/src/hono-current-user-endpoints.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-current-user-endpoints.test.ts @@ -1,20 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #4073 — `registerStandardEndpoints` used to gate two unrelated things: +// #4073 — the three current-user endpoints are this plugin's SOLE supply, and +// nothing may gate them. // -// * 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. +// `packages/rest` and `packages/runtime` register no `/me/*` route at all. 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. They used to sit behind `registerStandardEndpoints` +// alongside a duplicate `/data` + discovery surface — one flag over two opposite +// things, so turning it off took the console down with it. The split landed +// first (#4144); the duplicate half has since been deleted outright, and these +// endpoints are all this plugin serves beyond the socket itself. // -// 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. +// These tests pin both halves of that end state: the `/me/*` routes mount on a +// plain boot, and nothing of the retired surface comes back. import { describe, it, expect, vi } from 'vitest'; import { Hono } from 'hono'; @@ -32,8 +31,8 @@ const ME_ROUTES = [ * 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 }); +async function boot() { + const plugin = new HonoServerPlugin({ port: 0, cors: false }); const readyHooks: Array<() => unknown> = []; const ctx: any = { logger: { info() {}, debug() {}, warn() {}, error() {} }, @@ -57,41 +56,31 @@ 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); +describe('current-user endpoints are this plugin\'s own supply (#4073)', () => { + it('mounts /me/* on a plain boot', async () => { + const app = await boot(); for (const route of ME_ROUTES) { - expect(paths(app), `${route} must survive registerStandardEndpoints:false`).toContain(route); + expect(paths(app), `${route} must be mounted — this plugin is their only provider`).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); + it('mounts nothing of the retired convenience surface', async () => { + // The raw `/data` CRUD and the third discovery payload are gone (#4073); + // their owners are @objectstack/rest and the runtime dispatcher. If any + // of these come back, one route has two owners again (ADR-0076 D11). + const registered = paths(await boot()); 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); + it('answers /me/* rather than 404ing', async () => { + const app = await boot(); // 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. + // console used to get when these sat behind the retired flag. const permissions = await app.request('http://localhost/api/v1/auth/me/permissions'); expect(permissions.status).toBe(200); expect(await permissions.json()).toEqual({ authenticated: false }); @@ -105,18 +94,16 @@ describe('current-user endpoints are not gated by registerStandardEndpoints (#40 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); - }); + /** + * The case that used to live here pinned `/me/*` registering BEFORE this + * plugin's own CRUD block, because plugin-auth mounts a catch-all over + * `/api/v1/auth/*` and `/auth/me/*` won the match only by registering + * first. Both halves of that are gone: the CRUD block was deleted (#4073), + * and #4088 made the auth catch-all fall through for paths better-auth does + * not implement, so the surface no longer depends on `kernel.use()` order + * at all. `plugin-auth/auth-catchall-fallthrough.test.ts` pins that + * directly, registering the catch-all FIRST — the order that used to fail. + */ }); // cloud#924 — #4079 freed these three from the wrong flag, but left the SUPPLY diff --git a/packages/plugins/plugin-hono-server/src/hono-discovery.test.ts b/packages/plugins/plugin-hono-server/src/hono-discovery.test.ts deleted file mode 100644 index ed427192e1..0000000000 --- a/packages/plugins/plugin-hono-server/src/hono-discovery.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// #4018 — the standard-endpoints convenience surface used to serve a fully -// STATIC discovery: a hardcoded `routes` table listing auth/packages/analytics/ -// workflow/automation/ai/notifications/i18n/storage/ui whether or not anything -// mounted them. That is the "advertise a route that 404s" class ADR-0076 D12 -// exists to kill, and it put this surface out of step with the two real -// discovery builders (the dispatcher's `getDiscoveryInfo`, the protocol's -// `getDiscovery`), which both compute per service. -// -// These tests drive the REAL Hono app the plugin builds — no mocked route -// table — so what they assert about "is this route mounted" is what a request -// would actually find. - -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'; - -/** - * Register the standard endpoints on a real Hono app and hand it back so tests - * can drive HTTP requests directly. `installedPlugins` seeds `kernel.hasPlugin`, - * which is how this surface decides whether a real discovery owner is present. - */ -function bootStandardEndpoints(installedPlugins: string[] = []) { - // 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: () => ({ - hasPlugin: (name: string) => installedPlugins.includes(name), - getService: () => undefined, - }), - registerService: () => {}, - 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`. - const rawApp = (plugin as any).server.getRawApp(); - registerCurrentUserEndpoints({ rawApp, ctx }); - (plugin as any).registerDiscoveryAndCrudEndpoints(ctx); - return rawApp; -} - -async function discoveryRoutes(app: any): Promise> { - const res = await app.request('http://localhost/api/v1/discovery'); - expect(res.status).toBe(200); - return (await res.json()).data.routes; -} - -describe('standalone discovery — routes are computed, never hardcoded (#4018)', () => { - it('advertises nothing for services this host does not mount', async () => { - const app = bootStandardEndpoints(); - const routes = await discoveryRoutes(app); - - // Every one of these was advertised unconditionally by the old static - // table; on a bare host each 404s, so none may be advertised now. - for (const family of [ - 'metadata', 'packages', 'analytics', 'workflow', 'automation', - 'ai', 'notifications', 'i18n', 'storage', 'ui', - ]) { - expect(routes[family], `${family} advertised but nothing mounts it`).toBeUndefined(); - } - }); - - it('advertises the families this surface really mounts, and they answer', async () => { - const app = bootStandardEndpoints(); - const routes = await discoveryRoutes(app); - - // The block mounts /data/:object CRUD and the /auth/me/* helpers, so - // both bases genuinely carry routes. (`/auth` on a bare host is only - // those helpers — the sign-in surface arrives with plugin-auth.) - expect(routes.data).toBe('/api/v1/data'); - expect(routes.auth).toBe('/api/v1/auth'); - - // Not a 404: the advertised base really has a live endpoint under it. - const res = await app.request('http://localhost/api/v1/data/thing'); - expect(res.status).not.toBe(404); - }); - - it('picks up a family once another plugin mounts it — including a wildcard', async () => { - const app = bootStandardEndpoints(); - // How plugin-auth / the dispatcher really mount: a wildcard under the - // family base, and a concrete child route. - app.all('/api/v1/i18n/*', (c: any) => c.json({})); - app.post('/api/v1/analytics/query', (c: any) => c.json({})); - - const routes = await discoveryRoutes(app); - expect(routes.i18n).toBe('/api/v1/i18n'); - expect(routes.analytics).toBe('/api/v1/analytics'); - }); - - it('reflects a mount that lands AFTER discovery is wired (not snapshotted)', async () => { - const app = bootStandardEndpoints(); - - // Sibling plugins keep registering through the rest of kernel:ready — - // i.e. after this hook wired `/discovery` — and Hono seals its matcher - // on the first request, so a mount can only ever arrive in this window. - // A table built at wiring time would miss it; one built per request - // does not. - app.get('/api/v1/workflow/definitions', (c: any) => c.json({})); - - expect((await discoveryRoutes(app)).workflow).toBe('/api/v1/workflow'); - }); - - it('does not count a wildcard mounted ABOVE the family base', async () => { - const app = bootStandardEndpoints(); - // Global middleware and a prefix-wide wildcard match every path but - // mount no family — treating them as a mount would re-advertise the - // whole table, which is the bug. - app.use('*', async (_c: any, next: any) => next()); - app.use('/api/v1/*', async (_c: any, next: any) => next()); - - const routes = await discoveryRoutes(app); - expect(routes.storage).toBeUndefined(); - expect(routes.ai).toBeUndefined(); - }); - - it('never advertises realtime — no HTTP surface exists for it (D12, #2462)', async () => { - const app = bootStandardEndpoints(); - expect((await discoveryRoutes(app)).realtime).toBeUndefined(); - }); - - it('still reports transactionalBatch=false — /batch ships with @objectstack/rest (#3298)', async () => { - const app = bootStandardEndpoints(); - const res = await app.request('http://localhost/api/v1/discovery'); - const body = await res.json(); - - expect(body.data.capabilities.transactionalBatch).toEqual({ enabled: false }); - expect((await app.request('http://localhost/api/v1/batch', { method: 'POST' })).status).toBe(404); - }); -}); - -describe('standalone discovery — single owner (ADR-0076 D11 / OQ#9)', () => { - it('cedes /discovery AND /.well-known to the dispatcher when it is installed', async () => { - const app = bootStandardEndpoints([RUNTIME_DISPATCHER_PLUGIN]); - - // The dispatcher registers both during plugin start() — before this - // kernel:ready hook — so it already served them; we must not publish a - // third payload behind it. - expect((await app.request('http://localhost/api/v1/discovery')).status).toBe(404); - expect((await app.request('http://localhost/.well-known/objectstack')).status).toBe(404); - }); - - it('cedes /discovery to @objectstack/rest but keeps /.well-known (REST never registers it)', async () => { - const app = bootStandardEndpoints([REST_API_PLUGIN]); - - expect((await app.request('http://localhost/api/v1/discovery')).status).toBe(404); - - const wellKnown = await app.request('http://localhost/.well-known/objectstack'); - expect(wellKnown.status).toBe(302); - expect(wellKnown.headers.get('location')).toBe('/api/v1/discovery'); - }); - - it('owns both when no real discovery plugin is on the kernel', async () => { - const app = bootStandardEndpoints(); - - expect((await app.request('http://localhost/api/v1/discovery')).status).toBe(200); - expect((await app.request('http://localhost/.well-known/objectstack')).status).toBe(302); - }); -}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index 47fa61a7f3..8093344a13 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -103,33 +103,6 @@ describe('HonoServerPlugin', () => { await expect(plugin.start(context as PluginContext)).resolves.not.toThrow(); }); - it('standalone discovery advertises transactionalBatch=false — the /batch route is not mounted here (#3298)', async () => { - // This standalone surface registers CRUD + auth only; the cross-object - // /batch endpoint ships with @objectstack/rest. declared === enforced: - // discovery must report the capability as disabled so a client never - // drops its non-atomic fallback against this backend. - const plugin = new HonoServerPlugin({ registerStandardEndpoints: true }); - await plugin.init(context as PluginContext); - - // Capture the routes the producer registers on the raw app. - const routes: Record = {}; - const rawApp = { - get: vi.fn((path: string, h: any) => { routes[`GET ${path}`] = h; }), - post: vi.fn((path: string, h: any) => { routes[`POST ${path}`] = h; }), - use: vi.fn(), - }; - (plugin as any).server.getRawApp = () => rawApp; - (plugin as any).registerDiscoveryAndCrudEndpoints(context); - - const handler = routes['GET /api/v1/discovery']; - expect(handler).toBeDefined(); - const c = { json: vi.fn((x: any) => x) }; - const res = handler(c); - expect(res.data.capabilities.transactionalBatch).toEqual({ enabled: false }); - // Sanity: the standalone surface really has no /batch route (so `false` - // is honest, not merely conservative). - expect(routes['POST /api/v1/batch']).toBeUndefined(); - }); it('should configure static files and SPA fallback when enabled', async () => { const plugin = new HonoServerPlugin({ diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 718af23a43..7257faa8ba 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -1,17 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { - Plugin, PluginContext, IDataEngine, - shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, -} from '@objectstack/core'; -import { - RestServerConfig, - type ApiRoutes, -} from '@objectstack/spec/api'; -import { - makeExecutionContextResolver, - registerCurrentUserEndpoints, -} from './current-user-endpoints'; +import { Plugin, PluginContext } from '@objectstack/core'; +import { RestServerConfig } from '@objectstack/spec/api'; +import { registerCurrentUserEndpoints } from './current-user-endpoints'; import { HonoHttpServer, HonoCorsOptions } from './adapter'; import { cors } from 'hono/cors'; import { serveStatic } from '@hono/node-server/serve-static'; @@ -47,39 +38,6 @@ export interface HonoPluginOptions { * Controls automatic endpoint generation and API behavior */ restConfig?: RestServerConfig; - /** - * Whether to register the standalone CRUD + discovery convenience surface: - * raw `POST/GET /api/v1/data/:object` (create + read only) and - * `GET /api/v1/discovery` / `/.well-known/objectstack`. - * - * @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 false - */ - registerStandardEndpoints?: boolean; /** * Whether to load endpoints from API Registry * @default true @@ -203,43 +161,18 @@ export function buildTimingDetail(timing: PerfTiming): string { } /** - * The two plugins that own a REAL, computed `/discovery` (ADR-0076 D11 / OQ#9). - * `@objectstack/rest` serves `metadata-protocol`'s registry-driven `getDiscovery()`; - * the runtime dispatcher serves `HttpDispatcher.getDiscoveryInfo()`. When either is - * on the kernel, this convenience surface cedes the route to it rather than - * publishing a third payload. + * The two plugins that own the data + discovery APIs (ADR-0076 D11 / OQ#9). + * `@objectstack/rest` serves `metadata-protocol`'s registry-driven + * `getDiscovery()` and full `/data` CRUD; the runtime dispatcher serves + * `HttpDispatcher.getDiscoveryInfo()` and the service routes. + * + * This plugin serves neither (#4073) — it is a transport adapter. These names + * exist only so a boot that mounts NO owner can say so out loud instead of + * leaving callers to diagnose a bare 404. */ const REST_API_PLUGIN = 'com.objectstack.rest.api'; const RUNTIME_DISPATCHER_PLUGIN = 'com.objectstack.runtime.dispatcher'; -/** - * Base-path segment for every route family this surface can advertise, keyed by - * its `ApiRoutes` field (`spec/api/discovery.zod.ts`). Typed against the spec so a - * renamed or dropped key breaks the build here instead of drifting silently. - * - * This is a path map, not a capability list — nothing here is advertised unless a - * matching route is actually registered (see `advertisableRoutes`). - * - * `realtime` is deliberately absent (ADR-0076 D12, #2462): service-realtime is an - * in-process pub/sub bus with no HTTP surface anywhere, so it could never be - * mounted under `/api/v1/realtime` and listing it would only invite a stale entry. - */ -const DISCOVERY_ROUTE_SEGMENTS: Partial> = { - data: 'data', - metadata: 'meta', - auth: 'auth', - packages: 'packages', - analytics: 'analytics', - workflow: 'workflow', - approvals: 'approvals', - automation: 'automation', - ai: 'ai', - notifications: 'notifications', - i18n: 'i18n', - storage: 'storage', - ui: 'ui', -}; - /** * Hono Server Plugin * @@ -278,9 +211,6 @@ export class HonoServerPlugin implements Plugin { constructor(options: HonoPluginOptions = {}) { this.options = { port: 3000, - // 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 @@ -633,36 +563,32 @@ export class HonoServerPlugin implements Plugin { registerCurrentUserEndpoints({ rawApp: this.server.getRawApp(), ctx }); }); - if (this.options.registerStandardEndpoints) { - 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.', - ); - } - }); - } + // This plugin is a TRANSPORT ADAPTER: it owns the socket, the middleware + // and the three current-user endpoints above, and nothing else. The data + // and discovery APIs belong to `@objectstack/rest` and the runtime + // dispatcher — one owner per route (ADR-0076 D11). The convenience + // surface that used to duplicate them here is gone (#4073). + // + // A host that mounts neither owner therefore has NO data or discovery + // API. That is a real composition (a bare transport host), and its + // failure mode without this warning is a bare 404 with nothing pointing + // at the cause. Absence must be loud — the same honesty rule #4018 + // applied to the discovery cede. Checked on `kernel:ready` so every + // `kernel.use()` has landed, and silent whenever an 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 or discovery API is mounted on this server. HonoServerPlugin is a ' + + 'transport adapter and serves neither (#4073). Mount `createRestApiPlugin` ' + + 'from @objectstack/rest for full CRUD behind the gate stack, or ' + + '`createDispatcherPlugin` from @objectstack/runtime.', + ); + } + }); // Open the listening socket on kernel:listening — this fires // STRICTLY AFTER every kernel:ready handler completes, so all @@ -696,219 +622,6 @@ export class HonoServerPlugin implements Plugin { }); } - /** - * Discovery for this standalone convenience surface. Two rules, both ADR-0076: - * - * **1. Single owner (D11 / OQ#9).** `@objectstack/rest` and the runtime - * dispatcher each serve a real, computed discovery; whichever is on the kernel - * owns `${prefix}/discovery` and we do not register it. Hono is - * first-registration-wins and both of those register during plugin `start()` — - * i.e. before this `kernel:ready` hook — so they already shadowed this handler - * in every composed deployment; ceding cannot change which payload a client - * sees, it just stops us shipping a third one that nobody serves. (The - * dispatcher cedes to REST on the same `hasPlugin` predicate, without probing - * REST's `enableDiscovery`; matching it keeps the three surfaces consistent.) - * - * `/.well-known/objectstack` is ceded to the dispatcher ONLY — REST never - * registers it, so in a REST-without-dispatcher composition this redirect is - * the only thing pointing a `.well-known`-first client at `/discovery`. - * - * **2. Computed, never hardcoded (D12, #4018).** When we do own `/discovery`, - * `routes` is derived from the routes actually registered on this Hono app. - * The table used to be a hardcoded list of every ObjectStack domain — - * `/analytics`, `/workflow`, `/ai`, … — advertised whether or not anything - * mounted them, which is exactly the "advertise a route that 404s" class D12 - * exists to kill: a standalone host with no service plugins advertised the - * whole platform while serving `/data` CRUD and two `/auth/me/*` helpers. - * Both real discovery surfaces compute per service - * (`hasXxx ? route : undefined`); this one computes per registration, which on - * a bare host is the stricter and more honest question — service-registered - * does not imply route-mounted here, because nothing bridges services to HTTP - * on this surface (that bridging IS the dispatcher). - */ - private registerDiscoveryEndpoints(ctx: PluginContext, rawApp: any, prefix: string) { - const kernel = ctx.getKernel() as { hasPlugin?(name: string): boolean } | undefined; - const hasPlugin = (name: string) => - typeof kernel?.hasPlugin === 'function' && kernel.hasPlugin(name); - - if (hasPlugin(RUNTIME_DISPATCHER_PLUGIN)) { - ctx.logger.info( - `/.well-known/objectstack ceded to ${RUNTIME_DISPATCHER_PLUGIN} (single owner)`, - ); - } else { - rawApp.get('/.well-known/objectstack', (c: any) => c.redirect(`${prefix}/discovery`)); - } - - const discoveryOwner = - hasPlugin(REST_API_PLUGIN) ? REST_API_PLUGIN - : hasPlugin(RUNTIME_DISPATCHER_PLUGIN) ? RUNTIME_DISPATCHER_PLUGIN - : undefined; - if (discoveryOwner) { - ctx.logger.info(`${prefix}/discovery ceded to ${discoveryOwner} (single owner)`); - return; - } - - // Built per request, not here: sibling plugins keep registering routes - // through the rest of `kernel:ready`, and the socket only opens on - // `kernel:listening` — so by the time a request can arrive the route table - // is final, while a table snapshotted now would miss every later mount. - rawApp.get(`${prefix}/discovery`, (c: any) => c.json({ data: this.buildDiscovery(prefix) })); - - ctx.logger.info('Registered discovery endpoints', { prefix }); - } - - /** The discovery payload served when this surface owns `/discovery`. */ - private buildDiscovery(prefix: string) { - return { - version: 'v1', - apiName: 'ObjectStack API', - routes: this.advertisableRoutes(prefix), - capabilities: { - // This standalone Hono surface registers CRUD + auth only (see - // `registerDiscoveryAndCrudEndpoints`) — it does NOT mount the - // cross-object `/batch` route, - // which ships with `@objectstack/rest`. `declared === enforced` - // (#3298): report `transactionalBatch: false` so a client never - // drops its non-atomic fallback against a backend that lacks the - // endpoint. When `@objectstack/rest` is mounted it serves its own - // discovery, which reports the real value from the runtime engine. - transactionalBatch: { enabled: false }, - }, - }; - } - - /** - * `ApiRoutes` computed from the live Hono route table: a family is advertised - * iff some route is registered AT its base path or UNDER it. - * - * `app.routes` is every registration on this app — adapter routes, `getRawApp()` - * routes (how plugin-auth mounts `${basePath}/*`), mounted sub-apps and `use()` - * middleware alike — so this sees what a request will actually hit, not what a - * parallel bookkeeping list believes. Requiring the base or a `/`-separated - * child means a wildcard ABOVE the base (global `/*` middleware, `/api/v1/*`) - * never counts as a mount, while `/api/v1/auth/*` and `/api/v1/data/:object` - * both do — and `/api/v1/me/apps` does not pass for `metadata` (`/api/v1/meta`). - */ - private advertisableRoutes(prefix: string): Partial> { - const app = this.server.getRawApp() as { routes?: Array<{ path?: string }> }; - const registered = Array.isArray(app?.routes) ? app.routes : []; - const routes: Partial> = {}; - for (const [key, segment] of Object.entries(DISCOVERY_ROUTE_SEGMENTS)) { - const base = `${prefix}/${segment}`; - const mounted = registered.some( - (r) => typeof r?.path === 'string' && (r.path === base || r.path.startsWith(`${base}/`)), - ); - if (mounted) routes[key as keyof ApiRoutes] = base; - } - return routes; - } - - /** - * Register discovery and basic CRUD endpoints. - * Called when `registerStandardEndpoints` is true, before the server starts listening. - */ - private registerDiscoveryAndCrudEndpoints(ctx: PluginContext) { - const rawApp = this.server.getRawApp(); - const prefix = '/api/v1'; - - this.registerDiscoveryEndpoints(ctx, rawApp, prefix); - - // ── Anonymous-deny gate (ADR-0056 D2, #2567) ────────────────────────── - // These raw `/data/:object` routes delegate straight to ObjectQL. They - // are only *shadowed* by the REST plugin's gated `/data` routes when - // that plugin registers the same paths FIRST — so before this gate the - // platform's anonymous posture depended on plugin registration order: a - // load-order change silently reopened anonymous data access with no test - // failing. Gating here makes the deny decision a property of THIS entry - // point too, so security no longer depends on who registered first. - // - // [#3963] Anonymous access to object data is denied unconditionally — - // the `requireAuth` opt-out is retired, so there is no posture to read - // or warn about here. An authenticated / system caller always passes. - // Returns a 401 Response when the caller is anonymous, else null - // (caller proceeds). Delegates the decision to the - // shared `shouldDenyAnonymous` (#2567) so every HTTP seam stays in - // lockstep. `isSystem` is never set on inbound HTTP (internal-only), so - // it cannot be forged to bypass this. - const denyAnonymous = (c: any, execCtx: any): Response | null => - shouldDenyAnonymous({ userId: execCtx?.userId, isSystem: execCtx?.isSystem }) - ? c.json(ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS) - : null; - - // Basic CRUD data endpoints — delegate to ObjectQL service directly - const getObjectQL = () => ctx.getService('objectql'); - - // Session → ExecutionContext. Shared with the always-registered - // current-user endpoints, which resolve the same principal. - const resolveCtx = makeExecutionContextResolver(ctx); - - // Create - rawApp.post(`${prefix}/data/:object`, async (c: any) => { - const ql = getObjectQL(); - if (!ql) return c.json({ error: 'Data service not available' }, 503); - const object = c.req.param('object'); - const data = await c.req.json().catch(() => ({})); - const execCtx = await resolveCtx(c); - const denied = denyAnonymous(c, execCtx); - if (denied) return denied; - try { - const res = await ql.insert(object, data, { context: execCtx } as any); - const record = { ...data, ...res }; - return c.json({ object, id: record.id, record }); - } catch (err: any) { - if (err?.code === 'PERMISSION_DENIED' || err?.name === 'PermissionDeniedError') { - return c.json({ error: err.message ?? 'Forbidden' }, 403); - } - throw err; - } - }); - - // Get by ID - rawApp.get(`${prefix}/data/:object/:id`, async (c: any) => { - const ql = getObjectQL(); - if (!ql) return c.json({ error: 'Data service not available' }, 503); - const object = c.req.param('object'); - const id = c.req.param('id'); - const execCtx = await resolveCtx(c); - const denied = denyAnonymous(c, execCtx); - if (denied) return denied; - try { - let all = await ql.find(object, { context: execCtx } as any); - if (!all) all = []; - const match = all.find((i: any) => i.id === id); - return match ? c.json({ object, id, record: match }) : c.json({ error: 'Not found' }, 404); - } catch (err: any) { - if (err?.code === 'PERMISSION_DENIED' || err?.name === 'PermissionDeniedError') { - return c.json({ error: err.message ?? 'Forbidden' }, 403); - } - throw err; - } - }); - - // Find / List - rawApp.get(`${prefix}/data/:object`, async (c: any) => { - const ql = getObjectQL(); - if (!ql) return c.json({ error: 'Data service not available' }, 503); - const object = c.req.param('object'); - const execCtx = await resolveCtx(c); - const denied = denyAnonymous(c, execCtx); - if (denied) return denied; - try { - let all = await ql.find(object, { context: execCtx } as any); - if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value; - if (!all) all = []; - return c.json({ object, records: all, total: all.length }); - } catch (err: any) { - if (err?.code === 'PERMISSION_DENIED' || err?.name === 'PermissionDeniedError') { - return c.json({ error: err.message ?? 'Forbidden' }, 403); - } - throw err; - } - }); - - ctx.logger.debug('Registered standard CRUD data endpoints', { prefix }); - } - /** * Destroy phase - Stop server */ diff --git a/packages/plugins/plugin-hono-server/src/hono-standard-endpoints-default.test.ts b/packages/plugins/plugin-hono-server/src/hono-standard-endpoints-default.test.ts deleted file mode 100644 index 521e270e4c..0000000000 --- a/packages/plugins/plugin-hono-server/src/hono-standard-endpoints-default.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// 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[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); - }); -}); diff --git a/packages/plugins/plugin-hono-server/src/hono-transport-only.test.ts b/packages/plugins/plugin-hono-server/src/hono-transport-only.test.ts new file mode 100644 index 0000000000..f7987742ec --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/hono-transport-only.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4073, end state: this plugin is a TRANSPORT ADAPTER. + * + * It owns the socket, the middleware and the three current-user endpoints — + * nothing else. The data and discovery APIs belong to `@objectstack/rest` and + * the runtime dispatcher: one owner per route (ADR-0076 D11). The convenience + * surface that duplicated them here, and the `registerStandardEndpoints` flag + * that gated it, are deleted. + * + * This replaces the suite that pinned that flag's default. It pins the property + * the flag was in the way of, plus the one behaviour the deletion adds: + * + * A host that mounts neither owner has NO data or discovery API. That is a real + * composition, and its failure mode is a bare 404 with nothing pointing at the + * cause. So the boot says it out loud — the same honesty rule #4018 applied to + * the discovery cede — and stays silent when an owner IS mounted, so + * transport-only compositions are not nagged toward something that no longer + * exists. + */ + +import { describe, it, expect } from 'vitest'; +import { HonoServerPlugin } from './hono-plugin'; + +const RETIRED_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, with a warn recorder and a configurable `hasPlugin` so + * the composed/bare distinction is testable. + */ +async function boot(installedPlugins: string[] = [], options: Record = {}) { + const plugin = new HonoServerPlugin({ port: 0, cors: false, ...options } as never); + 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) => 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); +const pointer = (warns: string[]) => warns.filter((w) => w.includes('No data or discovery API')); + +describe('#4073 end state — the plugin serves transport and /me/* only', () => { + it('mounts the current-user endpoints and none of the retired surface', async () => { + const registered = paths((await boot()).app); + for (const route of ME_ROUTES) expect(registered).toContain(route); + for (const route of RETIRED_ROUTES) { + expect(registered, `${route} is owned by REST/the dispatcher now`).not.toContain(route); + } + }); + + it('has no way to opt the retired surface back in', async () => { + // The flag is GONE, not merely defaulted off. Passing the old option + // must resurrect nothing — if this fails, the surface came back and a + // route has two owners again. + const registered = paths((await boot([], { registerStandardEndpoints: true })).app); + for (const route of RETIRED_ROUTES) expect(registered).not.toContain(route); + }); + + it('a BARE boot says so loudly: one warn naming both owners', async () => { + const hit = pointer((await boot([])).warns); + expect(hit, 'a host with no API owner must be told why /data and /discovery are absent') + .toHaveLength(1); + // The message must carry the remedy, not just the diagnosis. + expect(hit[0]).toContain('@objectstack/rest'); + expect(hit[0]).toContain('@objectstack/runtime'); + expect(hit[0]).toContain('#4073'); + }); + + it('a REST-composed boot stays quiet', async () => { + expect(pointer((await boot(['com.objectstack.rest.api'])).warns)).toHaveLength(0); + }); + + it('a dispatcher-composed boot stays quiet', async () => { + expect(pointer((await boot(['com.objectstack.runtime.dispatcher'])).warns)).toHaveLength(0); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts index da9f3f0757..e163fa47a9 100644 --- a/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts +++ b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts @@ -1,15 +1,21 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. // -// End-to-end regression for #3361 on the STANDALONE Hono CRUD surface (the -// minimal server used when `@objectstack/rest` is not mounted). It drives a real -// request through the perf middleware AND the real `/api/v1/data/:object` -// handler — whose `resolveCtx` resolves the principal — instead of a handler -// that calls `allowPerfDisclosure()` by hand "as the dispatcher would" (the gap -// the existing unit tests left invisible). An admin sending `X-OS-Debug-Timing` -// must get a `Server-Timing` header; a member and an anonymous caller must not. +// End-to-end regression for #3361. It drives a real request through the perf +// middleware AND a real handler that resolves the principal itself — instead of +// a handler calling `allowPerfDisclosure()` by hand "as the dispatcher would", +// which is the gap the unit tests left invisible. An admin sending +// `X-OS-Debug-Timing` must get a `Server-Timing` header; a member and an +// anonymous caller must not. +// +// Originally driven through the standalone `/api/v1/data/:object` surface. That +// surface is gone (#4073), so this runs against `/auth/me/permissions` — one of +// the three endpoints this plugin still owns, and one that resolves the same +// principal from the same permission-set fixtures. The property under test is +// the perf gate, not the route. import { describe, it, expect } from 'vitest'; import { HonoServerPlugin } from './hono-plugin'; +import { registerCurrentUserEndpoints } from './current-user-endpoints'; import type { PluginContext } from '@objectstack/core'; /** @@ -67,17 +73,17 @@ async function setup() { const plugin = new HonoServerPlugin({ cors: false }); const ctx = fakeCtx({ objectql: makeQl(), auth: makeAuth() }); await (plugin as any).init(ctx); - // Register the real standard CRUD/data endpoints (normally wired on - // kernel:ready) so `/api/v1/data/:object` runs its real `resolveCtx`. - (plugin as any).registerDiscoveryAndCrudEndpoints(ctx); + // Register the real current-user endpoints (normally wired on kernel:ready) + // so the request runs their real principal resolution. + registerCurrentUserEndpoints({ rawApp: (plugin as any).server.getRawApp(), ctx }); const app = (plugin as any).server.getRawApp(); return { app }; } -describe('Hono standalone data route — admin-gated Server-Timing (#3361 e2e)', () => { +describe('Hono current-user route — admin-gated Server-Timing (#3361 e2e)', () => { it('emits Server-Timing for a platform admin sending X-OS-Debug-Timing', async () => { const { app } = await setup(); - const res = await app.request('/api/v1/data/widget', { + const res = await app.request('/api/v1/auth/me/permissions', { headers: { cookie: 'admin', 'X-OS-Debug-Timing': '1' }, }); expect(res.status).toBe(200); @@ -88,26 +94,28 @@ describe('Hono standalone data route — admin-gated Server-Timing (#3361 e2e)', it('withholds Server-Timing from an ordinary member (same debug header)', async () => { const { app } = await setup(); - const res = await app.request('/api/v1/data/widget', { + const res = await app.request('/api/v1/auth/me/permissions', { headers: { cookie: 'member', 'X-OS-Debug-Timing': '1' }, }); expect(res.status).toBe(200); expect(res.headers.get('Server-Timing')).toBeNull(); }); - it('withholds Server-Timing from an anonymous caller (401, no header)', async () => { + it('withholds Server-Timing from an anonymous caller', async () => { const { app } = await setup(); - const res = await app.request('/api/v1/data/widget', { + const res = await app.request('/api/v1/auth/me/permissions', { headers: { 'X-OS-Debug-Timing': '1' }, }); - // requireAuth defaults on → anonymous is denied, and nothing opened the gate. - expect(res.status).toBe(401); + // This endpoint answers anonymous callers with `{authenticated:false}` + // rather than denying them; what must hold is that nothing opened the + // disclosure gate. + expect(res.status).toBe(200); expect(res.headers.get('Server-Timing')).toBeNull(); }); it('does NOT emit for an admin when no debug header is sent (opt-in only)', async () => { const { app } = await setup(); - const res = await app.request('/api/v1/data/widget', { headers: { cookie: 'admin' } }); + const res = await app.request('/api/v1/auth/me/permissions', { headers: { cookie: 'admin' } }); expect(res.status).toBe(200); expect(res.headers.get('Server-Timing')).toBeNull(); }); diff --git a/packages/qa/http-conformance/src/conformance.integration.test.ts b/packages/qa/http-conformance/src/conformance.integration.test.ts index 614227b935..03ea6c9110 100644 --- a/packages/qa/http-conformance/src/conformance.integration.test.ts +++ b/packages/qa/http-conformance/src/conformance.integration.test.ts @@ -30,7 +30,7 @@ type AdapterCase = { const ADAPTERS: AdapterCase[] = [ { label: 'node', makePlugin: () => new NodeServerPlugin({ port: 0 }) }, - { label: 'hono', makePlugin: () => new HonoServerPlugin({ port: 0, registerStandardEndpoints: false }) }, + { label: 'hono', makePlugin: () => new HonoServerPlugin({ port: 0 }) }, ]; /** diff --git a/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts b/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts index 98ba07cea6..2b34ddf1dc 100644 --- a/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts @@ -24,7 +24,7 @@ describe('GET /ready over a real HTTP server (integration)', () => { beforeAll(async () => { kernel = new LiteKernel(); // port 0 → OS-assigned free port; resolved via getPort() after listening. - kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints: true })); + kernel.use(new HonoServerPlugin({ port: 0 })); kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); await kernel.bootstrap(); diff --git a/packages/runtime/src/notifications.hono.integration.test.ts b/packages/runtime/src/notifications.hono.integration.test.ts index 7f894c4021..6d77b02758 100644 --- a/packages/runtime/src/notifications.hono.integration.test.ts +++ b/packages/runtime/src/notifications.hono.integration.test.ts @@ -83,7 +83,7 @@ describe('in-app notifications over a real hono server (integration, #3362)', () await kernel.use(new MessagingServicePlugin({ reliableDelivery: false })); await kernel.use(fakeAuthPlugin()); // port 0 → OS-assigned free port; resolved via getPort() after listening. - await kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints: true })); + await kernel.use(new HonoServerPlugin({ port: 0 })); await kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: false })); await kernel.bootstrap(); diff --git a/packages/runtime/src/route-parity.integration.test.ts b/packages/runtime/src/route-parity.integration.test.ts index b48c9430c8..8f40358b20 100644 --- a/packages/runtime/src/route-parity.integration.test.ts +++ b/packages/runtime/src/route-parity.integration.test.ts @@ -123,7 +123,7 @@ async function bootServe(stubOpts: { notification?: boolean; mcp?: boolean } = { // Register stub services FIRST so identity + capability services are live // for both registration paths (mirrors a provisioned `os serve`). kernel.use(stubServicesPlugin(stubOpts)); - kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints: true, cors: false })); + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: true })); await kernel.bootstrap(); const httpServer = kernel.getService('http.server'); diff --git a/packages/runtime/src/standard-endpoints-precedence.integration.test.ts b/packages/runtime/src/standard-endpoints-precedence.integration.test.ts deleted file mode 100644 index eab27f165e..0000000000 --- a/packages/runtime/src/standard-endpoints-precedence.integration.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #4073 — is `registerStandardEndpoints` really all DUPLICATE supply? - * - * The retirement plan (flip the default to `false` → observe a release → delete - * `registerDiscoveryAndCrudEndpoints`) rests on that premise. This file is the - * evidence, and it CORRECTS the first version of itself, which got the `/data` - * half wrong. - * - * That version mounted `createRestApiPlugin({})` against a STUB `objectql` - * service, saw `/api/v1/data/:object` 404 with the flag off, and concluded the - * flip removes the route. The 404 was real; the conclusion was not. REST - * generates its CRUD from the object registry, so it needs a real engine — a - * driver and at least one registered object — plus its own `api.api` config. - * Under-provision it and it serves nothing, which says nothing about REST and - * everything about the harness. - * - * `packages/client/src/client.environment-scoping.test.ts` had the answer the - * whole time: it boots `registerStandardEndpoints: false` and asserts - * `GET /api/v1/data/task` → 200, served by REST, with a comment saying that is - * the point ("skip hardcoded hono CRUD routes so createRestApiPlugin owns /data - * ... end-to-end"). - * - * Reproducing that provisioning, the answer is unambiguous: `/data/:object`, - * `/discovery` and `/.well-known/objectstack` return BYTE-IDENTICAL responses - * with the flag on and off. The surface is duplicate supply on both halves, and - * the flip is a no-op for a host that mounts REST or the dispatcher — which is - * every composed deployment, `os serve` included. - * - * What that does NOT license: flipping the default is still a real change for a - * BARE host that mounts neither, which is the composition the flag was written - * for. That is a deliberate API decision, not something this file decides. - * - * The lesson #4073 has now hit three times: "who serves this path" is a question - * about the composed, PROVISIONED runtime. Not about which plugin declares it - * (the issue's first correction), not about registration order (the second), and - * not about a minimal harness that merely boots (this one). - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import { LiteKernel } from '@objectstack/core'; -import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; -import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; -import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; -import { createRestApiPlugin } from '@objectstack/rest'; -import { createDispatcherPlugin } from './dispatcher-plugin.js'; - -function authStub() { - return { - metadata: { name: 'test-auth', version: '1.0.0' }, - async init(ctx: any) { - ctx.registerService('auth', { - api: { getSession: async () => ({ user: { id: 'test-user' } }) }, - }); - }, - } as any; -} - -/** - * `serve.ts` order — Hono (line 1173) long before REST (1872) and the dispatcher - * (1883) — with REST provisioned the way a real deployment provisions it. - */ -async function boot(registerStandardEndpoints: boolean) { - const kernel = new LiteKernel(); - kernel.use(new ObjectQLPlugin()); - kernel.use(authStub()); - kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints, cors: false })); - kernel.use( - createRestApiPlugin({ - // Routing probe with no auth stack mounted — same opt-out the client - // suites use (ADR-0056 D2). - api: { api: { requireAuth: false } as any }, - }), - ); - kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); - await kernel.bootstrap(); - - // After bootstrap, mirroring the client suites: the engine service only - // exists once plugins have initialised, and REST's `/data/:object` is a - // generic route rather than one emitted per object, so registering the - // object afterwards is enough to make the read resolve. - const ql = kernel.getService('objectql'); - ql.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); - ql.registerObject({ - name: 'task', - label: 'Task', - fields: { name: { type: 'text', label: 'Name' } }, - } as never); - - const server = kernel.getService('http.server'); - return { kernel, baseUrl: `http://127.0.0.1:${server.getPort()}` }; -} - -let kernel: LiteKernel | undefined; -afterEach(async () => { - if (kernel) await Promise.race([kernel.shutdown(), new Promise((r) => setTimeout(r, 5_000))]); - kernel = undefined; -}); - -/** Status + body for a path, so two flag positions can be compared exactly. */ -async function probe(baseUrl: string, path: string) { - const res = await fetch(`${baseUrl}${path}`); - return { status: res.status, body: (await res.text()).slice(0, 400) }; -} - -describe('#4073 — registerStandardEndpoints against a PROVISIONED REST', () => { - /** - * The retirement question stated exactly: does turning the flag off change - * what a caller sees? Compare the two positions rather than asserting a - * status, because a status alone is what misled the earlier version of this - * file — `/data/task` answers 404 `OBJECT_NOT_FOUND` here (the object is - * registered after bootstrap, so the handler reaches the engine and the - * ENGINE says no), which is a route that WORKS. A routing 404 would be - * `{"error":"Not found"}`. Same-response-in-both-positions is the property - * the flip actually needs, and it does not depend on that distinction. - */ - for (const path of ['/api/v1/data/task?top=5', '/api/v1/discovery', '/.well-known/objectstack']) { - it(`${path} answers identically with the flag ON and OFF`, async () => { - const on = await boot(true); - kernel = on.kernel; - const withFlag = await probe(on.baseUrl, path); - await on.kernel.shutdown(); - - const off = await boot(false); - kernel = off.kernel; - const withoutFlag = await probe(off.baseUrl, path); - - expect( - withoutFlag, - `${path} differs between flag positions — the #4073 flip is NOT a no-op for this path`, - ).toEqual(withFlag); - }, 60_000); - } - - /** - * ...and the routes are live, not uniformly absent — otherwise the parity - * above would be satisfied by two identical 404s. - */ - it('the compared routes are actually mounted', async () => { - const booted = await boot(false); - kernel = booted.kernel; - - const data = await probe(booted.baseUrl, '/api/v1/data/task?top=5'); - // Reached the engine: OBJECT_NOT_FOUND is the engine's answer, not Hono's - // unmatched-route 404. - expect(data.body, '/data/:object must reach the engine').toContain('OBJECT_NOT_FOUND'); - - const discovery = await probe(booted.baseUrl, '/api/v1/discovery'); - expect(discovery.status, '/discovery must be served').toBe(200); - expect(discovery.body).toContain('"routes"'); - }, 30_000); -}); From 772c364c68ddd0c157da752e2deec8d53b659232 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 04:41:32 +0000 Subject: [PATCH 2/3] test(dogfood): retire the raw-hono /data row from the #2567 anonymous-deny ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dogfood Regression Gate caught the deletion, which is the ledger doing its job: STALE covers — surface no longer in source: data:hono-plugin.ts:POST /data/:object data:hono-plugin.ts:GET /data/:object/:id data:hono-plugin.ts:GET /data/:object `anonymous-deny-hono-data` classified an entry point that no longer exists, so the row goes with its surface. Three places referenced it and each needed a different answer: - the matrix row itself — deleted, with a note on the section above it that the #2567 invariant is UNCHANGED: it has one fewer implementation to hold it in, not one fewer guarantee; - `HIGH_RISK` — the id dropped from the list; - the ratchet's own negative test `(a) a row that DROPS its covers` — it used that row as its FIXTURE, so it would have passed vacuously or not at all. Repointed at `anonymous-deny-meta`, which covers a live surface, so the test still has a real classification to lose. This one mattered: silently letting it fall through would have disarmed the ratchet that just caught this. `showcase-anonymous-deny-surfaces.dogfood.test.ts` keeps every assertion — the surface-level 401 is enforced by @objectstack/rest, the route's sole owner now. Only its comments, which described the deleted raw-hono routes, are updated. Verified: authz-conformance 8/8, full dogfood suite 430 passed / 3 skipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gEeLZ4oTeSXG6fKG1r3vd --- packages/qa/dogfood/test/authz-conformance.matrix.ts | 10 +++++----- packages/qa/dogfood/test/authz-conformance.test.ts | 12 +++++++++--- .../showcase-anonymous-deny-surfaces.dogfood.test.ts | 8 +++++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index dc723e0e26..8c04a546da 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -80,15 +80,15 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // just REST `/data`. Each sibling surface that reaches ObjectQL consults the // same unconditional anonymous-deny (#2567); these rows pin every entry point // so a new ungated surface (or a silent regression) fails CI, not review. + // + // The raw-hono `/data` row was retired with its surface (#4073): those routes + // were duplicate supply that this plugin no longer serves, so there is no + // entry point left to gate there. The invariant is unchanged — it simply has + // one fewer implementation to hold it in. { id: 'anonymous-deny-meta', summary: 'anonymous-deny on the metadata endpoints (#2567 surface 1)', state: 'enforced', enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth → shouldDenyAnonymous) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', covers: ['meta:rest-server.ts:registerMetadataEndpoints', 'meta:http-dispatcher.ts:handleMetadata'] }, - { id: 'anonymous-deny-hono-data', summary: 'anonymous-deny on the raw-hono standard /data routes (#2567 surface 3)', state: 'enforced', - enforcement: 'plugin-hono-server/hono-plugin.ts denyAnonymous gate (shouldDenyAnonymous) on the standard /data routes — unconditional anonymous-deny, mirroring rest-server.ts', - proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', - covers: ['data:hono-plugin.ts:POST /data/:object', 'data:hono-plugin.ts:GET /data/:object/:id', 'data:hono-plugin.ts:GET /data/:object'], - note: 'These routes delegate straight to ObjectQL and were only shadowed when the REST plugin registered the same paths FIRST — so the posture depended on plugin registration order (a load-order change silently reopened it, no test failing). Gating each route makes the deny decision a property of this entry point too. Handler-level proof in plugin-hono-server/hono-anonymous-deny.test.ts.' }, // ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity // admission). Neither surface is reachable by a client today; these rows diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index c077ad2a7a..6854101281 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -145,7 +145,10 @@ const HIGH_RISK = [ 'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile', // #2567 — every anonymous-deny HTTP surface is high-risk: it guards the // same object data as REST `/data` through a sibling entry point. - 'anonymous-deny-meta', 'anonymous-deny-hono-data', + // (the raw-hono `/data` row retired with its surface — #4073 deleted the + // entry point rather than gating it, so there is nothing left to mark + // high-risk there) + 'anonymous-deny-meta', // #2948/#3003 — write-integrity face: without the strip, `readonly: true` // is false compliance (declared ≠ enforced) and approval/status columns are // one direct PATCH away from self-approval. @@ -183,11 +186,14 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => { }); it('(a) a row that DROPS its covers → UNCLASSIFIED surface failure', () => { + // Fixture moved off the raw-hono `/data` row when #4073 deleted that + // surface. `anonymous-deny-meta` covers a live one, so the ratchet still + // has a real classification to lose. const m = clone(); - const row = m.find((r) => r.id === 'anonymous-deny-hono-data')!; + const row = m.find((r) => r.id === 'anonymous-deny-meta')!; row.covers = []; const problems = checkLedger(m, opts(() => discoverAnonymousDenySurfaces())); - expect(problems.some((p) => /UNCLASSIFIED surface/.test(p) && /data:hono-plugin\.ts/.test(p))).toBe(true); + expect(problems.some((p) => /UNCLASSIFIED surface/.test(p) && /meta:/.test(p))).toBe(true); }); it('(b) a NEW ungated route appearing in source → UNCLASSIFIED surface failure', () => { diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index 78d0e05daf..1150d4b42d 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -3,9 +3,11 @@ // #2567 — anonymous posture must be UNIFORM across HTTP surfaces, not just the // REST `/data` routes proven by showcase-anonymous-deny.dogfood.test.ts. Before // this fix, on a `requireAuth` deployment `/data/*` denied anonymous callers -// while three sibling surfaces reached ObjectQL without the gate: +// while sibling surfaces reached ObjectQL without the gate: // - the metadata endpoints (`/meta`) -// - the raw-hono standard `/data` routes (order-dependent shadowing) +// - the raw-hono standard `/data` routes (order-dependent shadowing) — that +// surface has since been deleted outright (#4073), which removes the entry +// point rather than gating it // // This proof boots the real showcase HTTP stack ON THE PLATFORM DEFAULT (the // verify harness passes no `requireAuth` override, so the flipped secure default @@ -39,7 +41,7 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => expect(r.status, 'authenticated metadata read must clear the auth gate').not.toBe(401); }); - // ── /data (surface-level; raw-hono handler proven in plugin-hono-server) ── + // ── /data (surface-level; served by @objectstack/rest, its sole owner) ── it('anonymous READ of the data surface is denied (401)', async () => { const r = await stack.api(OBJ, { method: 'GET' }); expect(r.status, 'anonymous data read must be 401').toBe(401); From 0226471771a155662c45058a4154978f4cba7fd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 04:42:55 +0000 Subject: [PATCH 3/3] docs(authorization): drop the retired raw-hono /data surface from the #2567 matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drift check flagged nine docs; eight carry none (the quickstart and authentication ones were settled in #4280, `http-protocol.mdx` entered only via the CORS change this branch merged from main, and `delegated-administration.mdx` came along as a same-package sibling with nothing to say about this). `authorization.mdx` was real, and it is the kind that matters most: it documents a SECURITY invariant, and it named the deleted surface twice — once in the list of HTTP surfaces the anonymous-deny decision is uniform across, and once in the implementation list, as `plugin-hono-server` `denyAnonymous`. Left alone, the permissions doc would promise a gate on an entry point that no longer exists. Both now read as history, matching how the same sentence already handles the retired GraphQL endpoint: the surface LEFT the matrix (v17, #4073) because the duplicate was deleted, not because it went ungated. The invariant is unchanged — `shouldDenyAnonymous` still backs REST `/data` and the dispatcher's metadata/AI paths, and the source-enumerating ratchet still fails CI on a new ungated surface. It just has one fewer door to hold shut. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gEeLZ4oTeSXG6fKG1r3vd --- content/docs/permissions/authorization.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 153d71e9af..917b2ee9e0 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -48,7 +48,7 @@ site — the file you read when behavior surprises you. | # | Gate | What it decides | Enforcement site | Failure direction | |---|---|---|---|---| -| 1 | **Anonymous deny** | No identity → HTTP 401. **Uniform across every HTTP surface that reaches object data** (#2567): REST `/data`, the metadata endpoints (`/meta`), and the raw-hono standard `/data` routes (the dispatcher GraphQL endpoint left the matrix when the GraphQL surface was removed in v17 — `/graphql` now 404s) — one shared decision, so a caller denied on `/data` can't read the same rows through a sibling door. **Default-on** (ADR-0056 D2): serving the whole data plane publicly requires an explicit `api.requireAuth: false` opt-out, which logs a boot warning. Narrower public surfaces do **not** need it — each derives its own authorization from a declaration rather than from the deployment posture: control plane (`/auth`, `/health`, `/discovery`) is allow-listed; public form submission carries a `publicFormGrant` (ADR-0056 Option A); share-links validate their token then read as SYSTEM; and an anonymous **GET** of the book/doc read surface is admitted so `book.audience: 'public'` works under the secure default, with the ADR-0046 §6.7 audience gate — `'public'` only, fail-closed — doing the authorizing (#3963). | `packages/core/src/security/anonymous-deny.ts` `shouldDenyAnonymous` — called by `rest-server.ts` `enforceAuth`, the dispatcher `handleMetadata`/`handleAI`, and `plugin-hono-server` `denyAnonymous` (default in `packages/spec/src/api/rest-server.zod.ts`); a source-enumerating ratchet in `authz-conformance.test.ts` fails CI if a new surface ships ungated | fail-closed | +| 1 | **Anonymous deny** | No identity → HTTP 401. **Uniform across every HTTP surface that reaches object data** (#2567): REST `/data` and the metadata endpoints (`/meta`) — the raw-hono standard `/data` routes left the matrix when that duplicate surface was deleted in v17 (#4073), and the dispatcher GraphQL endpoint left when the GraphQL surface was removed (`/graphql` now 404s) — one shared decision, so a caller denied on `/data` can't read the same rows through a sibling door. **Default-on** (ADR-0056 D2): serving the whole data plane publicly requires an explicit `api.requireAuth: false` opt-out, which logs a boot warning. Narrower public surfaces do **not** need it — each derives its own authorization from a declaration rather than from the deployment posture: control plane (`/auth`, `/health`, `/discovery`) is allow-listed; public form submission carries a `publicFormGrant` (ADR-0056 Option A); share-links validate their token then read as SYSTEM; and an anonymous **GET** of the book/doc read surface is admitted so `book.audience: 'public'` works under the secure default, with the ADR-0046 §6.7 audience gate — `'public'` only, fail-closed — doing the authorizing (#3963). | `packages/core/src/security/anonymous-deny.ts` `shouldDenyAnonymous` — called by `rest-server.ts` `enforceAuth` and the dispatcher `handleMetadata`/`handleAI` (default in `packages/spec/src/api/rest-server.zod.ts`); a source-enumerating ratchet in `authz-conformance.test.ts` fails CI if a new surface ships ungated | fail-closed | | 2 | **Public-form grant** | An anonymous form submission carries a declaration-derived `publicFormGrant` authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No guest-portal configuration needed (anonymous principals hold the `guest` position). | `packages/plugins/plugin-security/src/security-plugin.ts` (ObjectQL middleware) | scope-limited allow | | 3 | **Object CRUD** | `allowRead/Create/Edit/Delete` (+ the destructive lifecycle class `allowTransfer/Restore/Purge`, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets. | `packages/plugins/plugin-security/src/permission-evaluator.ts` `checkObjectPermission` | fail-closed 403 | | 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`; **unset or unknown ⇒ `private`, fail-closed** — ADR-0090 D1) plus the external dial (`externalSharingModel`, ADR-0090 D11), manual record shares, criteria sharing rules (owner-type rules were removed from the authoring surface in v17 rather than left declared-but-skipped — [Sharing Rules](/docs/permissions/sharing-rules#recipient-types)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not positions). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | fail-closed to owner-only |