From d4fb2b0bad0c8a78fa6aed3ec03d0b923adb5214 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:36:10 +0000 Subject: [PATCH 1/6] fix(hono,runtime)!: the hono catch-all keeps a repeated query name as an array; /packages refuses a repeated keepData or overwrite The createHonoApp catch-all built the dispatcher's query with URLSearchParams.forEach into an object, so a repeated name reached dispatch() as its last value and every dispatcher arity refusal was unreachable on a @objectstack/hono host. It now mirrors plugin-hono-server's readQuery (c.req.queries() with the length normalisation, via Object.fromEntries). Guard first: DELETE /packages/:id ?keepData and POST /packages ?overwrite read a repeat as absent (keepData's absent drops object storage). Both now refuse a repeat with 400 VALIDATION_ERROR and repeatedQueryParamMessage, as enabled and version do, before the registry or the protocol is touched. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- packages/adapters/hono/src/index.ts | 43 ++++++- .../domains/packages-write-flag-arity.test.ts | 120 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 47 +++++++ 3 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 packages/runtime/src/domains/packages-write-flag-arity.test.ts diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index cb2375737fb..4089af0a973 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -328,6 +328,44 @@ async function readDispatchBody(c: Context): Promise { } } +/** + * The query the catch-all hands `dispatch()`: a REPEATED name as an array, a + * single-valued one as a plain string (#23214). + * + * The twin is `readQuery` in `@objectstack/plugin-hono-server` + * (`src/adapter.ts`), which every `plugin-hono-server` route reads its query + * through. It is mirrored here, not imported: it is module-private there, and + * that package's `exports` has only `.`. Keep the two in step. + * + * The catch-all used to copy `URLSearchParams.forEach` into an object, so a + * repeated name arrived as its LAST value: `?package=a&package=b` reached a + * dispatcher domain as `'b'`. Every arity rule the dispatcher declares + * (`repeatedQueryParamMessage`, answered `400` through `plugin-hono-server` + * and the node reference adapter) was unreachable on a `createHonoApp` host, + * and the request was answered with one of the two values the caller sent. + * The cli-lane ruling on #6878 decided the direction for every adapter: the + * handler must be able to SEE the ambiguity in order to refuse it. + * + * ⚠️ The normalisation is NOT optional. `c.req.queries()` returns an array + * for EVERY key, single-valued ones included (measured on hono 4.13: + * `?a=1&a=2&b=3` gives `{ a: ['1', '2'], b: ['3'] }`), so a bare swap would + * turn every single-value read point in the dispatcher into an array. + * + * Built with `Object.fromEntries`, never dynamic property writes: the keys + * come straight off the wire, and `fromEntries` creates own data properties + * only, so `?__proto__=x` is an own key named `__proto__` and no prototype is + * touched. The old `queryParams[key] = val` write silently dropped that name. + */ +function readDispatchQuery(c: Context): Record { + const all: Record = c.req.queries() ?? {}; + return Object.fromEntries( + Object.entries(all).map(([key, values]) => [ + key, + values.length > 1 ? values : values[0], + ]), + ) as Record; +} + /** * Creates a full-featured Hono app with all ObjectStack route dispatchers. * @@ -784,9 +822,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { body = await readDispatchBody(c); } - const queryParams: Record = {}; - const url = new URL(c.req.url); - url.searchParams.forEach((val, key) => { queryParams[key] = val; }); + // [#23214] A repeated name stays an array (`readDispatchQuery`). + const queryParams = readDispatchQuery(c); const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }, prefix); return toResponse(c, result); diff --git a/packages/runtime/src/domains/packages-write-flag-arity.test.ts b/packages/runtime/src/domains/packages-write-flag-arity.test.ts new file mode 100644 index 00000000000..d6bc24f7009 --- /dev/null +++ b/packages/runtime/src/domains/packages-write-flag-arity.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23214] The package WRITE doors refuse a REPEATED single-valued flag — + * `?keepData=` on `DELETE /packages/:id`, `?overwrite=` on `POST /packages` — + * as this domain already refuses `enabled` and `version`. + * + * ## Why these two + * + * Both were read as `query?. === 'true'`. An adapter that keeps a + * repeated name hands the domain an array (`plugin-hono-server`'s `readQuery` + * always has; `@objectstack/hono`'s catch-all does from this card), which + * compares unequal and so read as ABSENT. Measured before this change, driving + * `dispatch()` with the array: + * + * | request | answer | + * |---|---| + * | `DELETE /packages/:id?keepData=true&keepData=true` | `200`; `deletePackage` received no `keepData`, and the protocol drops object storage unless `keepData === true` — the tables the caller asked to keep | + * | `POST /packages?overwrite=true&overwrite=true` (installed id) | `409` "already exists", where one `true` overwrites with `201` | + * + * Both halves per name (AGENTS.md route rule 5): a REFUSAL pin — `400`, the + * nested `VALIDATION_ERROR`, the domain's one multiplicity sentence, and + * neither the registry nor the protocol touched — beside a PRESERVATION pin on + * what the service received for one value. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { repeatedQueryParamMessage } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PKG = 'com.acme.app'; + +/** Admitted by both write doors' capability gate. */ +const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata', 'studio.access'] }; + +/** The dispatcher over a REAL registry holding one installed, writable package, and a recording protocol. */ +function boot() { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as any).logLevel = 'silent'; + registry.installPackage({ id: PKG, name: PKG, namespace: 'app', version: '1.0.0', type: 'app' } as any); + const getPackage = vi.spyOn(registry, 'getPackage'); + const protocol = { + deletePackage: vi.fn(async (_r: any) => ({ success: true })), + installPackage: vi.fn(async (r: any) => ({ package: { manifest: r.manifest, status: 'installed', enabled: true } })), + }; + const services: Record = { objectql: { registry }, protocol }; + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + // The seam `dispatch()` resolves identity through (as the sibling dispatch-level pins do). + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...AUTHOR }); + return { dispatcher, protocol, getPackage }; +} + +interface Answer { status: number; body: any } + +async function call(dispatcher: HttpDispatcher, method: string, path: string, body: unknown, query: Record): Promise { + const res = await dispatcher.dispatch(method, path, body, query, { request: { headers: {} } } as any); + return { status: res.response?.status ?? (res.handled ? 200 : 0), body: res.response?.body }; +} + +function expectArityRefusal(answer: Answer, name: string, count = 2): void { + expect(answer.status, `expected 400 for a repeated "${name}", got ${JSON.stringify(answer)}`).toBe(400); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toBe(repeatedQueryParamMessage(name, count)); +} + +const UPGRADE = { manifest: { id: PKG, name: PKG, namespace: 'app', version: '1.0.1', type: 'app' } }; + +describe('[#23214] DELETE /packages/:id — `keepData` is one flag', () => { + it.each([ + [['true', 'true']], + [['1', 'true']], + [['false', 'true', 'true']], + ])('refuses a repeated `keepData` (%j) before the registry or the protocol is touched', async (keepData) => { + const { dispatcher, protocol, getPackage } = boot(); + expectArityRefusal(await call(dispatcher, 'DELETE', `/packages/${PKG}`, undefined, { keepData }), 'keepData', keepData.length); + expect(protocol.deletePackage).not.toHaveBeenCalled(); + expect(getPackage).not.toHaveBeenCalled(); + }); + + it.each([ + ['true', { packageId: PKG, keepData: true }], + ['1', { packageId: PKG, keepData: true }], + [['true'], { packageId: PKG, keepData: true }], + [undefined, { packageId: PKG }], + ])('preserves one `keepData` (%j): deletePackage receives %j', async (keepData, received) => { + const { dispatcher, protocol } = boot(); + const answer = await call(dispatcher, 'DELETE', `/packages/${PKG}`, undefined, keepData === undefined ? {} : { keepData }); + expect(answer.status).toBe(200); + expect(protocol.deletePackage).toHaveBeenCalledTimes(1); + expect(protocol.deletePackage.mock.calls[0][0]).toEqual(received); + }); +}); + +describe('[#23214] POST /packages — `overwrite` is one flag', () => { + it('refuses a repeated `overwrite` before the registry or the protocol is touched', async () => { + const { dispatcher, protocol, getPackage } = boot(); + expectArityRefusal(await call(dispatcher, 'POST', '/packages', UPGRADE, { overwrite: ['true', 'true'] }), 'overwrite'); + expect(protocol.installPackage).not.toHaveBeenCalled(); + expect(getPackage).not.toHaveBeenCalled(); + }); + + it.each(['true', ['true']])('preserves one `overwrite` (%j): the installed id is overwritten', async (overwrite) => { + const { dispatcher, protocol } = boot(); + const answer = await call(dispatcher, 'POST', '/packages', UPGRADE, { overwrite }); + expect(answer.status).toBe(201); + expect(protocol.installPackage).toHaveBeenCalledTimes(1); + expect(protocol.installPackage.mock.calls[0][0].manifest).toEqual(UPGRADE.manifest); + }); + + it('control: without `overwrite` the installed id is still refused 409', async () => { + const { dispatcher, protocol } = boot(); + const answer = await call(dispatcher, 'POST', '/packages', UPGRADE, {}); + expect(answer.status).toBe(409); + expect(protocol.installPackage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index cfc70f8d441..20fc9b7cd2c 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -938,6 +938,44 @@ function readEnabledFilter(raw: unknown): { kind: 'repeated'; count: number } | return { kind: 'value', value: parseBooleanParam('enabled', Array.isArray(raw) ? raw[0] : raw) }; } +/** + * [#23214] The package WRITE doors' single-valued query flags — `?keepData=` + * on `DELETE /packages/:id`, `?overwrite=` on `POST /packages` — answered the + * way `enabled` and `version` already are: a name supplied more than once is + * refused `400` with {@link repeatedQueryParamMessage}, the one sentence this + * door uses for the condition. + * + * Both were read as `query?. === 'true'`, so a repeat — an array, on + * every adapter that keeps repeats (`plugin-hono-server`'s `readQuery`, and + * `@objectstack/hono`'s catch-all since this card) — compared unequal and was + * read as ABSENT. For `keepData` that is the destructive default: the protocol + * drops object storage unless `keepData === true`, so a caller who asked twice + * to keep the tables had them dropped, answered `200`. For `overwrite` it + * turned an overwrite into a `409` whose sentence misnames the cause. + * + * A ONE-element array is one occurrence and is unwrapped in place, and an + * empty one is none (the `status`/`type` rule above). Asked before the + * registry or the protocol is touched. + * + * @returns the refusal to answer, or `null` with `query[name]` left as one value. + */ +function refuseRepeatedWriteFlag( + deps: DomainHandlerDeps, + query: any, + name: 'keepData' | 'overwrite', +): HttpDispatcherResult | null { + const raw = query?.[name]; + if (!Array.isArray(raw)) return null; + if (raw.length > 1) { + return { + handled: true, + response: deps.error(repeatedQueryParamMessage(name, raw.length), 400), + }; + } + query[name] = raw[0]; + return null; +} + /** * Whether a registry row counts as enabled, for {@link readEnabledFilter}'s * comparison (commit 0862063ba). @@ -1287,6 +1325,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // surfaced in Studio package-create dogfooding. Reject with 409 Conflict // instead. Intentional upgrade / re-install flows opt back in with // `overwrite: true` (wrapped body) or `?overwrite=true` (either form). + // [#23214] A repeated `?overwrite=` is refused here, ahead of the + // 409 and the registry read (`refuseRepeatedWriteFlag`). + const repeatedOverwrite = refuseRepeatedWriteFlag(deps, query, 'overwrite'); + if (repeatedOverwrite) return repeatedOverwrite; const overwrite = request?.overwrite === true || query?.overwrite === 'true' || query?.overwrite === true; if (!overwrite && registry.getPackage(pkgId)) { @@ -2171,6 +2213,11 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Deleting package "${id}"`); if (walled) return walled; + // [#23214] A repeated `?keepData=` is refused before the registry or + // the protocol is touched: read as absent, it dropped the tables + // the caller asked to keep (`refuseRepeatedWriteFlag`). + const repeatedKeepData = refuseRepeatedWriteFlag(deps, query, 'keepData'); + if (repeatedKeepData) return repeatedKeepData; // [#7560] ADR-0070: refuse BEFORE `uninstallPackage`, which is the // call that removed a platform package from the running registry // listing (and with it every object the package registers) until From 828096825420774d29c7330cca2e0e58c7985741 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:49:34 +0000 Subject: [PATCH 2/6] test(hono,http-conformance): pin the catch-all's query shape and the dispatcher's repeat refusals through a real createHonoApp; changeset The hono package pins the shape over its recording stub: a repeated name arrives as an array, every single-valued name exactly as the old line produced it (against a reference app running that line), and __proto__ an own key. The real-dispatcher half lives in http-conformance beside its six hono-* siblings, because the hono suite aliases @objectstack/runtime to a stub: the /meta by-name and /packages list refusals, and a repeated keepData refused before deletePackage, each with a single-value control. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23214-hono-query-multiplicity.md | 18 ++ .../hono/src/hono-query-multiplicity.test.ts | 124 ++++++++++++++ ...ono-query-multiplicity.conformance.test.ts | 154 ++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 .changeset/23214-hono-query-multiplicity.md create mode 100644 packages/adapters/hono/src/hono-query-multiplicity.test.ts create mode 100644 packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts diff --git a/.changeset/23214-hono-query-multiplicity.md b/.changeset/23214-hono-query-multiplicity.md new file mode 100644 index 00000000000..c4a63bb577d --- /dev/null +++ b/.changeset/23214-hono-query-multiplicity.md @@ -0,0 +1,18 @@ +--- +'@objectstack/hono': minor +'@objectstack/runtime': minor +--- + +fix(hono,runtime)!: `createHonoApp` hands the dispatcher a repeated query name as an array, so the dispatcher's arity rules refuse it instead of answering its last value; `/packages` refuses a repeated `keepData` or `overwrite` + +`@objectstack/hono`'s `createHonoApp` catch-all built the dispatcher's query by copying `URLSearchParams` into an object, so a name sent twice reached `HttpDispatcher.dispatch()` as its LAST value and the first was dropped without a word. Every arity rule the dispatcher declares was therefore unreachable on a `createHonoApp` host: `GET /meta/app/crm?package=a&package=b` was answered `200` for package `b`, where the same request through `@objectstack/plugin-hono-server` is refused. The catch-all now reads the query the way `@objectstack/plugin-hono-server` does: a name sent once is the string it always was, a repeated name is the array of its values, and the object is built without dynamic property writes, so a `__proto__` name is an ordinary own key. + +Two `/packages` write flags read a repeat as absent, and are now refused before the registry or the protocol is touched, as `enabled` and `version` already are. `DELETE /packages/:id?keepData=true&keepData=true` dropped the package's object tables, because the protocol keeps storage only for `keepData === true`, and was answered `200`. `POST /packages?overwrite=true&overwrite=true` answered `409` "already exists" for a package one `overwrite=true` replaces. Both doors were reached that way through `@objectstack/plugin-hono-server`, whose adapter already keeps repeats, and would have been through `createHonoApp` from this change. + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +What changes for you: send each single-valued query name once. On a `createHonoApp` host, a request that repeats one is now refused `400` in the dispatcher's envelope, `{ success: false, error: { code, message } }`, with the code and sentence the same request gets through `@objectstack/plugin-hono-server` (`VALIDATION_ERROR`, "The "NAME" query parameter was supplied N times…"; `INVALID_FILTER` on the `/data` filter slot), where it used to be answered with the last value. On every host, `DELETE /packages/:id` refuses a repeated `keepData` and `POST /packages` a repeated `overwrite`, with that same `VALIDATION_ERROR` and sentence. A name sent once is answered as before; genuinely multi-valued names such as `select` and `expand` now reach the dispatcher as the array they are. The `@objectstack/client` SDK sends each single-valued name once, and the Console's package delete sends `keepData` once. diff --git a/packages/adapters/hono/src/hono-query-multiplicity.test.ts b/packages/adapters/hono/src/hono-query-multiplicity.test.ts new file mode 100644 index 00000000000..b710c7c9ba0 --- /dev/null +++ b/packages/adapters/hono/src/hono-query-multiplicity.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23214] The `${prefix}/*` catch-all hands `dispatch()` a REPEATED query name + * as an array, and a single-valued one as the plain string it always handed. + * + * ## The defect + * + * The catch-all copied `new URL(c.req.url).searchParams.forEach` into an + * object, so `?package=a&package=b` reached `dispatch()` as `'b'`: the first + * value was dropped without a word, and every arity rule the dispatcher + * declares was unreachable on a `createHonoApp` host. It now reads the query as + * `plugin-hono-server`'s `readQuery` does (`readDispatchQuery`). + * + * ## What is pinned here + * + * 1. THE SHAPE: a repeated name arrives as the array of every value, in order. + * 2. THE CONTROL: every single-valued name arrives as the string the old line + * produced — checked against a reference Hono app that runs that exact + * line, so "unchanged" is a comparison, not a recollection. + * 3. `__proto__` is an OWN key and no prototype moves. The old dynamic write + * dropped the name silently; `Object.fromEntries` keeps it as data. + * + * The dispatcher is a recording fixture: this package's config aliases + * `@objectstack/runtime` to a stub for the whole suite. That the REAL + * dispatcher then refuses the repeat — `/meta`, `/packages`, `?keepData` — is + * pinned through a real `createHonoApp` in + * `packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Hono } from 'hono'; + +const seen: unknown[] = []; + +const mockDispatcher = { + getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }), + handleAuth: vi.fn(async () => ({ handled: true, response: { status: 200, body: { ok: true } } })), + dispatch: vi.fn(async (_method: string, _path: string, _body: unknown, query: unknown) => { + seen.push(query); + return { handled: true, response: { status: 200, body: { ok: true } } }; + }), +}; + +vi.mock('@objectstack/runtime', () => ({ + HttpDispatcher: function HttpDispatcher() { return mockDispatcher; }, +})); + +import { createHonoApp } from './index'; + +const PREFIX = '/api/v1'; +const app = createHonoApp({ kernel: { name: 'test-kernel' } as any, prefix: PREFIX, cors: false }); + +/** What `dispatch()` was handed as its query for one request through the catch-all. */ +async function dispatchedQuery(search: string): Promise { + seen.length = 0; + const res = await app.request(`http://localhost${PREFIX}/meta/app/crm${search}`); + expect(res.status).toBe(200); + expect(seen).toHaveLength(1); + return seen[0]; +} + +/** The catch-all's query line as it was before #23214 — the reference for "unchanged". */ +async function referenceQuery(search: string): Promise> { + let out: Record = {}; + const ref = new Hono(); + ref.get('/*', (c) => { + const queryParams: Record = {}; + const url = new URL(c.req.url); + url.searchParams.forEach((val, key) => { queryParams[key] = val; }); + out = queryParams; + return c.text('ok'); + }); + await ref.request(`http://localhost/x${search}`); + return out; +} + +beforeEach(() => { seen.length = 0; }); + +describe('[#23214] a repeated query name reaches dispatch() as an array', () => { + it.each([ + ['?package=a&package=b', { package: ['a', 'b'] }], + ['?package=a&package=b&package=c', { package: ['a', 'b', 'c'] }], + ['?keepData=true&keepData=true&layers=1', { keepData: ['true', 'true'], layers: '1' }], + ['?status=a&type=x&status=b', { status: ['a', 'b'], type: 'x' }], + ])('%s', async (search, expected) => { + expect(await dispatchedQuery(search)).toEqual(expected); + }); +}); + +describe('[#23214] a single-valued name arrives exactly as the old line produced it', () => { + it.each([ + [''], + ['?package=crm'], + ['?package=crm&preview=draft&locale=fr'], + ['?q=hello+world'], + ['?q=hello%20world&x=%E4%B8%AD'], + ['?na%6De=v'], + ['?empty='], + ['?flag'], + ['?select=id,name'], + ])('%j', async (search) => { + const got = await dispatchedQuery(search); + expect(got).toEqual(await referenceQuery(search)); + for (const value of Object.values(got)) expect(typeof value).toBe('string'); + }); +}); + +describe('[#23214] `__proto__` is an own key, and no prototype moves', () => { + it('keeps one `__proto__` as an own data property', async () => { + const got = await dispatchedQuery('?__proto__=x&polluted=1'); + expect(Object.hasOwn(got, '__proto__')).toBe(true); + expect(Object.getOwnPropertyDescriptor(got, '__proto__')?.value).toBe('x'); + expect(Object.getPrototypeOf(got)).toBe(Object.prototype); + expect(({} as any).polluted).toBeUndefined(); + expect(got.polluted).toBe('1'); + }); + + it('keeps a repeated `__proto__` as an own array', async () => { + const got = await dispatchedQuery('?__proto__=x&__proto__=y'); + expect(Object.getOwnPropertyDescriptor(got, '__proto__')?.value).toEqual(['x', 'y']); + expect(Object.getPrototypeOf(got)).toBe(Object.prototype); + }); +}); diff --git a/packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts b/packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts new file mode 100644 index 00000000000..f95990f078d --- /dev/null +++ b/packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23214] Through a REAL `createHonoApp` host, a single-valued query name + * supplied twice is refused by the dispatcher's arity rules — the composed-host + * pin. + * + * ## What this pins + * + * A host that mounts only `@objectstack/hono`'s `${prefix}/*` catch-all has no + * `RestServer` and no explicit dispatcher mounts: every request below goes + * catch-all → `HttpDispatcher.dispatch()` → the domain registry → the domain. + * The catch-all used to flatten a repeated name to its LAST value, so the + * dispatcher's arity refusals — `metaItemQueryRefusal` on the by-name `/meta` + * doors, `/packages`' list filters, and the `?keepData` guard on + * `DELETE /packages/:id` — never saw the repeat, and the request was answered + * with one of the two values the caller sent. Measured on this host before the + * change: `GET /meta/app/x?package=a&package=b` answered `200` with `getMetaItem` + * handed `packageId: 'b'`, and `GET /packages?status=installed&status=error` + * answered `200` with no packages. + * + * Each row holds the ANSWER ON THE WIRE — status, nested `error.code`, the + * named parameter and its count, and that the service was never reached — and + * each refusal has a single-value control on the same host, asserting what the + * service received. + * + * ## ⚠️ Why this lives HERE and not in `packages/adapters/hono` + * + * `packages/adapters/hono/vitest.config.ts` aliases `@objectstack/runtime` to a + * hand-written stub for its whole suite, and `createHonoApp` imports + * `HttpDispatcher` from that specifier — so no test in that package can reach + * the real dispatcher. That package pins the adapter's half (the query SHAPE: + * `src/hono-query-multiplicity.test.ts`). Here `@objectstack/hono` is aliased to + * this checkout's adapter SOURCE, and `@objectstack/runtime` is the real + * package (through its `dist/`, a ledgered pair in + * `scripts/check-test-source-alias.mjs`), as in the six sibling + * `hono-*.conformance.test.ts` files. A runtime change reaches these rows only + * once `@objectstack/runtime` is rebuilt, which is how CI builds the closure. + * + * This is not the multiplicity conformance file + * (`query-multiplicity.conformance.test.ts`), whose `describe.each` takes an + * `IHttpServer`; `createHonoApp` is not one. + * + * ## The one seam that is stubbed + * + * WHO the caller is comes from the dispatcher's identity step + * (`timedResolveExecutionContext`), replaced on the prototype (the instance + * `createHonoApp` constructs for itself is the one that runs) with an author + * holding `manage_metadata`. Everything after identity — the catch-all, the + * registry, the domain, the refusal, the adapter's rendering — is real code. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { HttpDispatcher } from '@objectstack/runtime'; +import { createHonoApp } from '@objectstack/hono'; + +const PREFIX = '/api/v1'; +const PKG = 'com.acme.app'; +const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata', 'studio.access'] }; + +/** + * A real `LiteKernel` carrying what these doors consult — the package registry + * (`objectql`), a recording `protocol`, and `security` for the `/meta` item + * read's gate — and the REAL `createHonoApp` over it. + */ +async function boot() { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as any).logLevel = 'silent'; + registry.installPackage({ id: PKG, name: PKG, namespace: 'app', version: '1.0.0', type: 'app' } as any); + const protocol = { + getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: { name, label: 'CRM' } })), + deletePackage: vi.fn(async (_r: any) => ({ success: true })), + }; + const kernel = new LiteKernel(); + kernel.use({ + metadata: { name: 'test-query-multiplicity-services', version: '1.0.0' }, + init: (c: any) => { + c.registerService('objectql', { registry }); + c.registerService('protocol', protocol); + c.registerService('security', { resolvePermissionSetNames: async () => [] }); + }, + } as any); + await kernel.bootstrap(); + + vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext') + .mockImplementation(async () => ({ ...AUTHOR })); + + const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX, cors: false }); + const send = async (method: string, path: string) => { + const res = await app.request(`http://localhost${PREFIX}${path}`, { method }); + const text = await res.text(); + return { status: res.status, body: JSON.parse(text) }; + }; + return { send, protocol }; +} + +afterEach(() => { vi.restoreAllMocks(); }); + +/** The dispatcher's multiplicity refusal, as this host renders it. */ +function expectRepeatRefused(answer: { status: number; body: any }, name: string, count = 2): void { + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toContain(`"${name}" query parameter was supplied ${count} times`); +} + +const packageIds = (answer: { body: any }): string[] => + (answer.body?.data?.packages ?? []).map((p: any) => p.manifest.id); + +describe('[#23214] a real createHonoApp host refuses a repeated single-valued name', () => { + it('GET /meta/:type/:name — a repeated `package` is refused before the item is read', async () => { + const { send, protocol } = await boot(); + expectRepeatRefused(await send('GET', '/meta/app/crm?package=a&package=b'), 'package'); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + }); + + it('GET /meta/:type/:name — control: one `package` is read as that package', async () => { + const { send, protocol } = await boot(); + const answer = await send('GET', '/meta/app/crm?package=a'); + expect(answer.status).toBe(200); + expect(protocol.getMetaItem).toHaveBeenCalledTimes(1); + expect(protocol.getMetaItem.mock.calls[0][0]).toMatchObject({ type: 'app', name: 'crm', packageId: 'a' }); + }); + + it.each([ + ['status', '?status=installed&status=error'], + ['enabled', '?enabled=true&enabled=false'], + ])('GET /packages — a repeated `%s` is refused', async (name, search) => { + const { send } = await boot(); + expectRepeatRefused(await send('GET', `/packages${search}`), name); + }); + + it('GET /packages — control: one `status` filters', async () => { + const { send } = await boot(); + const answer = await send('GET', '/packages?status=installed'); + expect(answer.status).toBe(200); + expect(packageIds(answer)).toEqual([PKG]); + }); + + it('DELETE /packages/:id — a repeated `keepData` is refused, and nothing is deleted', async () => { + const { send, protocol } = await boot(); + expectRepeatRefused(await send('DELETE', `/packages/${PKG}?keepData=true&keepData=true`), 'keepData'); + expect(protocol.deletePackage).not.toHaveBeenCalled(); + }); + + it('DELETE /packages/:id — control: one `keepData=true` reaches deletePackage as keepData', async () => { + const { send, protocol } = await boot(); + const answer = await send('DELETE', `/packages/${PKG}?keepData=true`); + expect(answer.status).toBe(200); + expect(protocol.deletePackage.mock.calls.map((c) => c[0])).toEqual([{ packageId: PKG, keepData: true }]); + }); +}); From e5da6a04aa3e087c82a7db14b28457d8e50dc926 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:56:08 +0000 Subject: [PATCH 3/6] test(hono): read own-property with hasOwnProperty.call, which the build config's ES2020 lib types Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- packages/adapters/hono/src/hono-query-multiplicity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapters/hono/src/hono-query-multiplicity.test.ts b/packages/adapters/hono/src/hono-query-multiplicity.test.ts index b710c7c9ba0..0c9b800f6de 100644 --- a/packages/adapters/hono/src/hono-query-multiplicity.test.ts +++ b/packages/adapters/hono/src/hono-query-multiplicity.test.ts @@ -109,7 +109,7 @@ describe('[#23214] a single-valued name arrives exactly as the old line produced describe('[#23214] `__proto__` is an own key, and no prototype moves', () => { it('keeps one `__proto__` as an own data property', async () => { const got = await dispatchedQuery('?__proto__=x&polluted=1'); - expect(Object.hasOwn(got, '__proto__')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(got, '__proto__')).toBe(true); expect(Object.getOwnPropertyDescriptor(got, '__proto__')?.value).toBe('x'); expect(Object.getPrototypeOf(got)).toBe(Object.prototype); expect(({} as any).polluted).toBeUndefined(); From 5c728003cccc901f55362d01ecce3232b9e98793 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:56:29 +0000 Subject: [PATCH 4/6] changeset: name the one input the hono catch-all now reads as absent (an empty query name), as plugin-hono-server does Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23214-hono-query-multiplicity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/23214-hono-query-multiplicity.md b/.changeset/23214-hono-query-multiplicity.md index c4a63bb577d..e6ceea8b173 100644 --- a/.changeset/23214-hono-query-multiplicity.md +++ b/.changeset/23214-hono-query-multiplicity.md @@ -15,4 +15,4 @@ Clause-②: no (narrowing) **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. -What changes for you: send each single-valued query name once. On a `createHonoApp` host, a request that repeats one is now refused `400` in the dispatcher's envelope, `{ success: false, error: { code, message } }`, with the code and sentence the same request gets through `@objectstack/plugin-hono-server` (`VALIDATION_ERROR`, "The "NAME" query parameter was supplied N times…"; `INVALID_FILTER` on the `/data` filter slot), where it used to be answered with the last value. On every host, `DELETE /packages/:id` refuses a repeated `keepData` and `POST /packages` a repeated `overwrite`, with that same `VALIDATION_ERROR` and sentence. A name sent once is answered as before; genuinely multi-valued names such as `select` and `expand` now reach the dispatcher as the array they are. The `@objectstack/client` SDK sends each single-valued name once, and the Console's package delete sends `keepData` once. +What changes for you: send each single-valued query name once. On a `createHonoApp` host, a request that repeats one is now refused `400` in the dispatcher's envelope, `{ success: false, error: { code, message } }`, with the code and sentence the same request gets through `@objectstack/plugin-hono-server` (`VALIDATION_ERROR`, "The "NAME" query parameter was supplied N times…"; `INVALID_FILTER` on the `/data` filter slot), where it used to be answered with the last value. On every host, `DELETE /packages/:id` refuses a repeated `keepData` and `POST /packages` a repeated `overwrite`, with that same `VALIDATION_ERROR` and sentence. A name sent once is answered as before; genuinely multi-valued names such as `select` and `expand` now reach the dispatcher as the array they are. One malformed input moves the other way, to match `@objectstack/plugin-hono-server`: a query pair with an EMPTY name (`?=x`) is no longer handed to the dispatcher, so a door with a closed parameter set reads it as absent where it used to refuse it as an unknown parameter `""`. The `@objectstack/client` SDK sends each single-valued name once, and the Console's package delete sends `keepData` once. From b9d049dd3faaabacd4b37ad0ebd163ef09a917b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:24:24 +0000 Subject: [PATCH 5/6] test(http-conformance): the hono query-multiplicity rows use a three-method registry double, not a SchemaRegistry The rows assert what the domain answers through the adapter; the real registry is pinned in the runtime suite, whose config declares the registry log level this suite's does not. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- ...ono-query-multiplicity.conformance.test.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts b/packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts index f95990f078d..eba06a0a101 100644 --- a/packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts @@ -52,7 +52,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { LiteKernel } from '@objectstack/core'; -import { SchemaRegistry } from '@objectstack/objectql'; import { HttpDispatcher } from '@objectstack/runtime'; import { createHonoApp } from '@objectstack/hono'; @@ -64,11 +63,25 @@ const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manag * A real `LiteKernel` carrying what these doors consult — the package registry * (`objectql`), a recording `protocol`, and `security` for the `/meta` item * read's gate — and the REAL `createHonoApp` over it. + * + * The registry is a three-method double over one installed package + * (`getAllPackages`, `getPackage`, `uninstallPackage` — every call these rows' + * `/packages` branches make, `isWritablePackage` included): the rows assert + * what the DOMAIN answers through the adapter, not what a registry does, and + * the domain's own pins over a real `SchemaRegistry` live in + * `packages/runtime/src/domains/packages-write-flag-arity.test.ts`. */ async function boot() { - const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); - (registry as any).logLevel = 'silent'; - registry.installPackage({ id: PKG, name: PKG, namespace: 'app', version: '1.0.0', type: 'app' } as any); + const installed = new Map([[PKG, { + manifest: { id: PKG, name: PKG, namespace: 'app', version: '1.0.0', type: 'app' }, + status: 'installed', + enabled: true, + }]]); + const registry = { + getAllPackages: () => [...installed.values()], + getPackage: (id: string) => installed.get(id), + uninstallPackage: (id: string) => installed.delete(id), + }; const protocol = { getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: { name, label: 'CRM' } })), deletePackage: vi.fn(async (_r: any) => ({ success: true })), From 692e8b1dcdbbc3a15a0ea0bf1c4adbc4f64fa082 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:08:16 +0000 Subject: [PATCH 6/6] =?UTF-8?q?changeset:=20Clause-=E2=91=A1=20yes=20(narr?= =?UTF-8?q?owing);=20the=20marker=20names=20the=20one=20malformed=20input?= =?UTF-8?q?=20that=20widens=20(an=20empty=20query=20name=20on=20a=20create?= =?UTF-8?q?HonoApp=20host)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23214-hono-query-multiplicity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/23214-hono-query-multiplicity.md b/.changeset/23214-hono-query-multiplicity.md index e6ceea8b173..6f3e2bbd9fd 100644 --- a/.changeset/23214-hono-query-multiplicity.md +++ b/.changeset/23214-hono-query-multiplicity.md @@ -9,9 +9,9 @@ fix(hono,runtime)!: `createHonoApp` hands the dispatcher a repeated query name a Two `/packages` write flags read a repeat as absent, and are now refused before the registry or the protocol is touched, as `enabled` and `version` already are. `DELETE /packages/:id?keepData=true&keepData=true` dropped the package's object tables, because the protocol keeps storage only for `keepData === true`, and was answered `200`. `POST /packages?overwrite=true&overwrite=true` answered `409` "already exists" for a package one `overwrite=true` replaces. Both doors were reached that way through `@objectstack/plugin-hono-server`, whose adapter already keeps repeats, and would have been through `createHonoApp` from this change. -Clause-②: no (narrowing) +Clause-②: yes (narrowing) - + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.