Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/23214-hono-query-multiplicity.md
Original file line number Diff line number Diff line change
@@ -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-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no exported declaration is added, removed or narrowed (the adapter's query reader and the domain's flag guard are both module-private). No stored row or package source changes, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a query accept set: on a `createHonoApp` host every dispatcher read point that refuses a repeated single-valued name now sees the repeat and refuses it, and on every host `DELETE /packages/:id` and `POST /packages` refuse a repeated `keepData` and `overwrite`. One malformed input widens: on a `createHonoApp` host, a query pair with an empty name is no longer refused, matching `@objectstack/plugin-hono-server`. The remedy is in the request a caller sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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. 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.
124 changes: 124 additions & 0 deletions packages/adapters/hono/src/hono-query-multiplicity.test.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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<Record<string, any>> {
let out: Record<string, any> = {};
const ref = new Hono();
ref.get('/*', (c) => {
const queryParams: Record<string, any> = {};
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.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();
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);
});
});
43 changes: 40 additions & 3 deletions packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,44 @@ async function readDispatchBody(c: Context): Promise<any> {
}
}

/**
* 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<string, string | string[]> {
const all: Record<string, string[]> = c.req.queries() ?? {};
return Object.fromEntries(
Object.entries(all).map(([key, values]) => [
key,
values.length > 1 ? values : values[0],
]),
) as Record<string, string | string[]>;
}

/**
* Creates a full-featured Hono app with all ObjectStack route dispatchers.
*
Expand Down Expand Up @@ -784,9 +822,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
body = await readDispatchBody(c);
}

const queryParams: Record<string, any> = {};
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);
Expand Down
Loading
Loading