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 fe531a76de..99706d9ecd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -365,6 +365,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/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 | 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 56cc4d78e1..72f12df0cc 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -109,33 +109,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 46021ec460..a13ba4f80b 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, @@ -52,39 +43,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 @@ -208,43 +166,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 * @@ -283,9 +216,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 @@ -627,36 +557,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 @@ -690,219 +616,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/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); 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); -});