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
47 changes: 47 additions & 0 deletions .changeset/route-ledger-live-mount-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/rest": patch
"@objectstack/runtime": patch
"@objectstack/plugin-hono-server": patch
"@objectstack/spec": patch
"@objectstack/service-settings": patch
---

Mount five ledgered-but-dead routes, and gate the class that hid them (#7526)

Three routes shipped in the ledgers, implemented in the dispatcher, and mounted
by nobody. Two of them answered a plausible `200` rather than a 404, which is
worse: `GET /meta/types` fell into the `/meta/:type` catch-all and returned
`{"type":"types","items":[]}`, shape-identical to `/meta/zzz_not_a_type`, and
`GET /meta/:type/:name/published` fell into the compound-name route and
returned a stub identical before publish **and for a name that does not exist**
— a route that structurally could not 404. `GET /meta/objects/:name/state/:field`
was the honest one: REST's `/meta` registrations topped out at three path
segments and it needs four, so it answered Hono's `notFound`. All three now
mount, `published` 404s for a bogus name, and the compound-name arity the SDK
documents (`getPublished('lead', 'views/all_leads')`) mounts with it.

The routes were the symptom. The route ledgers are a DECLARATION and every
guard built on them (#3563 / #3587 / #3636 / #3642) reads that union as an
OBSERVATION of what is mounted, so the whole audit chain was green on this
class by construction — `/meta/objects/:name/state/:field` counted as mounted
because it was ledgered. This adds the missing observation: a route-ledger ↔
live-mount parity gate that boots a real server, reads the mount table off it,
and asserts both directions — every ledgered route reachably mounted, every
mounted route ledgered. It never consults a second hand-written list of what is
mounted, and it PROBES reachability through the live router rather than
checking presence in a table, because a literal route registered after a
catch-all sibling is mounted and unreachable.

`IHttpServer` grows two optional, feature-detected members for it —
`getMountedRoutes()` (the live mount table, in registration order) and
`resolveMountedRoute(method, path)` (which registration answers a concrete
request, per the router itself) — implemented by the Hono adapter.

The gate found three more instances of the same class on its first run:
`GET /automation/actions`, `/automation/connectors` and `/automation/_status`
were ordered ahead of the `/:name` catch-all inside `dispatch()`, with a
comment saying the order was load-bearing, while the bridge that actually
mounts `/automation` registered `/:name` and never those three. They now mount.
It also found the unledgered live mounts: the four `/api/settings` routes get a
ledger of their own, and `GET /.well-known/objectstack` and the object-less
`POST /actions//:action` get rows in the dispatcher ledger.
7 changes: 6 additions & 1 deletion packages/client/src/client-url-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,12 @@ function compile(route: string, prefix: string, source: string): Pattern[] {
}

const PATTERNS: Pattern[] = [
...ROUTE_LEDGER.map((r) => r.route).filter((r) => !UNUSABLE_ROWS.has(r)).flatMap((r) => compile(r, '/api/v1', 'dispatcher')),
// `absolute` rows carry their own wire path and must NOT be prefixed —
// `/.well-known/*` lives at the site root by definition, and prefixing it
// here would compile a pattern nothing serves, i.e. certify a URL that does
// not exist (#7526).
...ROUTE_LEDGER.filter((r) => !UNUSABLE_ROWS.has(r.route))
.flatMap((r) => compile(r.route, r.absolute ? '' : '/api/v1', 'dispatcher')),
...REST_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'rest')),
...STORAGE_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'storage')),
...I18N_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'i18n')),
Expand Down
62 changes: 62 additions & 0 deletions packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,68 @@ export class HonoHttpServer implements IHttpServer {
this.app.patch(path, this.wrap(handler));
}

/**
* The LIVE mount table — every `(method, pattern)` this server registered,
* in registration order. See `IHttpServer.getMountedRoutes` for the
* contract; the ordering guarantee is load-bearing and honoured here
* because {@link registeredRoutes} is appended to inside the verb methods,
* on the same call that reaches `this.app`.
*
* A COPY, not the live array: a consumer of an OBSERVATION must not be able
* to edit the thing observed.
*/
getMountedRoutes(): ReadonlyArray<{ method: string; pattern: string }> {
return this.registeredRoutes.map((r) => ({ ...r }));
}

/**
* Ask the LIVE Hono router which registered route actually answers a
* concrete request — see `IHttpServer.resolveMountedRoute` for why
* "is it in the table" is not the same question.
*
* Implemented against `app.router.match()`, i.e. the very router object
* that serves production traffic, so the answer is Hono's own and cannot
* drift from it. `match()` returns the matched handlers in the order Hono
* would run them — middleware included — so this walks that list and takes
* the first entry whose `RouterRoute` corresponds to a route THIS adapter
* registered. Middleware and the raw-app catch-alls (static / SPA) are
* absent from {@link registeredRoutes} by construction, so they are skipped
* rather than mistaken for the answer.
*
* `undefined` means the router matched no registered route at all — the
* request would reach the `notFound` sink (404, or 405 via
* {@link allowedMethodsForPath}).
*/
resolveMountedRoute(method: string, path: string): { method: string; pattern: string } | undefined {
const router: any = (this.app as any)?.router;
if (!router || typeof router.match !== 'function') return undefined;
const verb = method.toUpperCase();
let matched: any;
try {
matched = router.match(verb, path);
} catch {
// A router that cannot answer is not evidence that nothing is
// mounted — say "unknown" rather than invent a verdict.
return undefined;
}
const handlers = Array.isArray(matched) ? matched[0] : undefined;
if (!Array.isArray(handlers)) return undefined;
for (const entry of handlers) {
// Each entry is `[[handler, RouterRoute], paramIndexMap]`; the
// RouterRoute carries the very `path` / `method` strings Hono was
// registered with.
const route = Array.isArray(entry) && Array.isArray(entry[0]) ? entry[0][1] : undefined;
const pattern = typeof route?.path === 'string' ? route.path : undefined;
const routeMethod = typeof route?.method === 'string' ? route.method.toUpperCase() : undefined;
if (!pattern || !routeMethod) continue;
const own = this.registeredRoutes.find(
(r) => r.pattern === pattern && r.method === routeMethod,
);
if (own) return { ...own };
}
return undefined;
}

/**
* The HTTP methods registered for a concrete request `path`, ignoring the
* request's own method. Empty when no registered route matches the path at
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `getMountedRoutes()` / `resolveMountedRoute()` — the adapter's answer to
* "what did this server REALLY mount, and which of it can actually be
* reached?" (#7526).
*
* WHY THIS EXISTS. Four route ledgers declare the platform's HTTP surface and
* every guard built on them reads that union as an observation of what is
* mounted. Three routes shipped ledgered-but-unmounted in one build because a
* declaration cannot audit itself. The parity gate that closes it
* (`packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`)
* rests entirely on the two members exercised here, so they get their own pins
* rather than being trusted implicitly by the gate that uses them.
*
* The first `describe` below is the MEASUREMENT the `/meta` fix is argued
* from. `rest-server.ts` registers `/meta/types` before `/meta/:type` and says
* in a comment that registering it after would silently break the route. That
* claim is about Hono's matching, not about ObjectStack, and a comment
* asserting third-party behaviour with no test under it is how the original
* defect stayed invisible.
*/

import { describe, it, expect, vi } from 'vitest';
import { HonoHttpServer } from './adapter';

const noop = () => {};

describe('Hono is first-match-wins (the premise the /meta registration order rests on)', () => {
it('a literal route registered AFTER a :param sibling never runs', async () => {
const server = new HonoHttpServer(0);
server.get('/api/v1/meta/:type', (_req, res: any) => res.json({ answered: ':type' }));
server.get('/api/v1/meta/types', (_req, res: any) => res.json({ answered: 'types' }));

const app = server.getRawApp();
const body = await (await app.request('/api/v1/meta/types')).json();

// The disguise this whole card is about: a 200 whose body is the
// catch-all's, indistinguishable from "that type is empty".
expect(body).toEqual({ answered: ':type' });
// …and the live router agrees about WHY, which is the fact the gate reads.
expect(server.resolveMountedRoute('GET', '/api/v1/meta/types'))
.toEqual({ method: 'GET', pattern: '/api/v1/meta/:type' });
});

it('the same literal registered BEFORE the :param sibling wins', async () => {
const server = new HonoHttpServer(0);
server.get('/api/v1/meta/types', (_req, res: any) => res.json({ answered: 'types' }));
server.get('/api/v1/meta/:type', (_req, res: any) => res.json({ answered: ':type' }));

const app = server.getRawApp();
expect(await (await app.request('/api/v1/meta/types')).json()).toEqual({ answered: 'types' });
expect(server.resolveMountedRoute('GET', '/api/v1/meta/types'))
.toEqual({ method: 'GET', pattern: '/api/v1/meta/types' });
// The catch-all still serves everything it used to.
expect(await (await app.request('/api/v1/meta/zzz')).json()).toEqual({ answered: ':type' });
});
});

describe('HonoHttpServer.getMountedRoutes', () => {
it('reports every registered (method, pattern) in registration order, spelled as passed', () => {
const server = new HonoHttpServer(0);
server.get('/api/v1/meta/types', noop);
server.get('/api/v1/meta/:type', noop);
server.put('/api/v1/meta/:type/:name', noop);
server.delete('/api/v1/meta/:type/:name', noop);

expect(server.getMountedRoutes()).toEqual([
{ method: 'GET', pattern: '/api/v1/meta/types' },
{ method: 'GET', pattern: '/api/v1/meta/:type' },
{ method: 'PUT', pattern: '/api/v1/meta/:type/:name' },
{ method: 'DELETE', pattern: '/api/v1/meta/:type/:name' },
]);
});

it('hands back a copy — a consumer cannot edit the thing it is observing', () => {
const server = new HonoHttpServer(0);
server.get('/api/v1/health', noop);

const first = server.getMountedRoutes() as Array<{ method: string; pattern: string }>;
first.push({ method: 'GET', pattern: '/api/v1/invented' });
first[0]!.pattern = '/api/v1/rewritten';

expect(server.getMountedRoutes()).toEqual([{ method: 'GET', pattern: '/api/v1/health' }]);
});

it('excludes middleware and the fallback seam — this answers "what did I register", not "what might respond"', () => {
const server = new HonoHttpServer(0);
server.use('/api/v1/*', (async (_req: any, _res: any, next: any) => next()) as any);
server.setFallbackHandler(noop);
server.get('/api/v1/health', noop);

expect(server.getMountedRoutes()).toEqual([{ method: 'GET', pattern: '/api/v1/health' }]);
});
});

describe('HonoHttpServer.resolveMountedRoute', () => {
it('resolves a concrete path to the pattern that would answer it', () => {
const server = new HonoHttpServer(0);
server.get('/api/v1/meta/:type/:name/published', noop);
server.get('/api/v1/meta/:type/:section/:name', noop);

expect(server.resolveMountedRoute('GET', '/api/v1/meta/object/lead/published'))
.toEqual({ method: 'GET', pattern: '/api/v1/meta/:type/:name/published' });
expect(server.resolveMountedRoute('GET', '/api/v1/meta/object/views/all_leads'))
.toEqual({ method: 'GET', pattern: '/api/v1/meta/:type/:section/:name' });
});

it('returns a pattern string identical to the getMountedRoutes entry (the gate compares them with ===)', () => {
const server = new HonoHttpServer(0);
server.get('/api/v1/meta/objects/:name/state/:field', noop);

const resolved = server.resolveMountedRoute('GET', '/api/v1/meta/objects/lead/state/status');
expect(server.getMountedRoutes().some(
(r) => r.method === resolved?.method && r.pattern === resolved?.pattern,
)).toBe(true);
});

it('is undefined when no registered route matches — the request would reach the notFound sink', () => {
const server = new HonoHttpServer(0);
server.get('/api/v1/meta/:type', noop);

expect(server.resolveMountedRoute('GET', '/api/v1/nothing/here')).toBeUndefined();
// A method mismatch on an existing PATH is also "no registered route
// answers", which is exactly what the 405 machinery is for.
expect(server.resolveMountedRoute('POST', '/api/v1/meta/object')).toBeUndefined();
});

it('skips middleware and the fallback: neither is a route this adapter registered', () => {
const server = new HonoHttpServer(0);
server.use('/api/v1/*', (async (_req: any, _res: any, next: any) => next()) as any);
server.get('/api/v1/meta/:type', noop);
server.setFallbackHandler(noop);

expect(server.resolveMountedRoute('GET', '/api/v1/meta/object'))
.toEqual({ method: 'GET', pattern: '/api/v1/meta/:type' });
// A path only the fallback could answer resolves to nothing — the
// fallback is not a mount, and a gate must not read it as one.
expect(server.resolveMountedRoute('GET', '/api/v1/apps/acme/webhook')).toBeUndefined();
});

it('does not run the handler it resolves', () => {
const handler = vi.fn();
const server = new HonoHttpServer(0);
server.get('/api/v1/meta/:type', handler);

server.resolveMountedRoute('GET', '/api/v1/meta/object');
expect(handler).not.toHaveBeenCalled();
});

it('is case-insensitive on the verb, so a ledger row\'s spelling cannot change the verdict', () => {
const server = new HonoHttpServer(0);
server.post('/api/v1/meta/_migrate-stored', noop);

expect(server.resolveMountedRoute('post', '/api/v1/meta/_migrate-stored'))
.toEqual({ method: 'POST', pattern: '/api/v1/meta/_migrate-stored' });
});
});
Loading
Loading