Skip to content

Commit d82f8c0

Browse files
authored
test(runtime): pin who serves /data and /discovery, blocking the #4073 flip on evidence (#4192)
#4073's retirement plan — flip `registerStandardEndpoints` to `false`, observe a release, delete `registerDiscoveryAndCrudEndpoints` — rests on the whole surface being DUPLICATE supply. The prerequisite split is already done (#4144 moved the three `/me/*` endpoints out of the gate), so the flip was the next step. Booted for real (real HonoServerPlugin + dispatcher + createRestApiPlugin, real listener, serve.ts registration order), the premise holds for only one half: rest=true flag=ON → /api/v1/data/account 401 (mounted, #2567 gate fired) rest=true flag=OFF → /api/v1/data/account 404 (nothing serves it) rest=false flag=ON → 401 rest=false flag=OFF → 404 `/discovery` + `/.well-known` are the safe half: they cede on an explicit `kernel.hasPlugin(rest|dispatcher)` check (#4018), so the dispatcher's computed payload answers either way — order-independent, verified both ways. `/data/:object` has no such check. Its shadowing was asserted purely on "REST registers first and wins", and that is not what the listener does. Flipping the default would turn the path into a 404 rather than handing it to REST. Narrower than it may read, and the file says so: REST is mounted with a minimal service set, so this proves "nothing in these compositions serves /data once the flag is off", not "REST never serves it". Enough to block a default flip that assumes otherwise — and the failing assertion names #4073 as the thing to re-read if a fully provisioned REST does mount it. No production code changes.
1 parent a946efd commit d82f8c0

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
test(runtime): pin who actually serves `/data` and `/discovery`, blocking the #4073 default flip on evidence
6+
7+
#4073 plans to retire `registerStandardEndpoints` by flipping its default to
8+
`false`, on the premise that everything it mounts is duplicate supply. Booted for
9+
real — real `HonoServerPlugin`, real dispatcher, real `createRestApiPlugin`, real
10+
listener, in `serve.ts`'s registration order — that premise holds for only one
11+
half of the surface:
12+
13+
- **`/discovery` + `/.well-known/objectstack` — safe.** They cede by an explicit
14+
`kernel.hasPlugin(rest|dispatcher)` check (#4018), so the dispatcher's computed
15+
payload answers whether the flag is on or off. Order-independent.
16+
- **`/data/:object` — not safe.** There is no cede, and the shadowing was
17+
asserted purely on "REST registers first and wins". With the flag OFF the path
18+
returns **404**, with REST mounted or not. The flag's raw surface is the only
19+
thing answering it in every composition this harness can boot.
20+
21+
So the flip is not the no-op the plan describes. This adds the harness that says
22+
so, asserting the current matrix, so the next attempt has to confront it rather
23+
than re-derive the assumption. No production code changes.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4073 — is `registerStandardEndpoints` really all DUPLICATE supply?
5+
*
6+
* The retirement plan (flip the default to `false` → observe a release → delete
7+
* `registerDiscoveryAndCrudEndpoints`) rests on that premise: every path the
8+
* flag mounts is also served, more completely, by `@objectstack/rest` or the
9+
* runtime dispatcher, so flipping the default changes nothing a caller can see.
10+
*
11+
* The premise is not uniform across the surface — the two halves reach it by
12+
* different mechanisms, and only one of them holds:
13+
*
14+
* - **`/discovery` + `/.well-known/objectstack` — ceded explicitly (#4018).**
15+
* `registerDiscoveryEndpoints` checks `kernel.hasPlugin(rest|dispatcher)` and
16+
* declines to register at all. That is order-independent, so it holds however
17+
* Hono resolves precedence. Verified below against the REAL dispatcher.
18+
*
19+
* - **`/data/:object` — no such check.** Its shadowing is asserted purely on
20+
* "REST registers first and wins". Booted for real, that does not hold: with
21+
* the flag off the path is a 404, with REST mounted or not. The flag's raw
22+
* surface is the ONLY thing answering `/api/v1/data/:object` in every
23+
* composition this harness can boot.
24+
*
25+
* So the flip is NOT the no-op the plan describes, and this file exists to keep
26+
* it from being made on the assumption. If you are here because you are about to
27+
* flip the default: first make `rest=true, flag=OFF` serve `/data/:object`, then
28+
* update these expectations in the same commit.
29+
*
30+
* Caveat, stated so the next reader does not over-read this: REST is mounted
31+
* here with a minimal service set (`objectql` + `auth`). A fully provisioned
32+
* `os serve` also has metadata/protocol services, and REST may mount `/data`
33+
* only once those resolve. What is proven is narrower than "REST never serves
34+
* `/data`" — it is "nothing in these compositions serves it once the flag is
35+
* off", which is enough to block a default flip that assumes otherwise.
36+
*/
37+
38+
import { describe, it, expect, afterEach } from 'vitest';
39+
import { LiteKernel } from '@objectstack/core';
40+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
41+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
42+
43+
function stubServices() {
44+
return {
45+
name: 'com.objectstack.test.standard-endpoints-precedence',
46+
version: '1.0.0',
47+
init: async (ctx: any) => {
48+
ctx.registerService('objectql', {
49+
async find() { return [{ id: 'r1' }]; },
50+
async findOne() { return { id: 'r1' }; },
51+
async insert(_o: string, d: any) { return d; },
52+
});
53+
ctx.registerService('auth', {
54+
async getSession() { return { user: { id: 'u1' }, session: {} }; },
55+
});
56+
},
57+
};
58+
}
59+
60+
async function boot(registerStandardEndpoints: boolean, withRest: boolean) {
61+
const kernel = new LiteKernel();
62+
kernel.use(stubServices());
63+
// `serve.ts` order, real plugins: HonoServerPlugin (line 1173) long before
64+
// REST (line 1872) and the dispatcher (line 1883).
65+
kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints, cors: false }));
66+
if (withRest) {
67+
const { createRestApiPlugin } = await import('@objectstack/rest');
68+
kernel.use(createRestApiPlugin({} as any));
69+
}
70+
kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: false }));
71+
await kernel.bootstrap();
72+
const server = kernel.getService<any>('http.server');
73+
return { kernel, baseUrl: `http://127.0.0.1:${server.getPort()}` };
74+
}
75+
76+
let kernel: LiteKernel | undefined;
77+
afterEach(async () => {
78+
if (kernel) await Promise.race([kernel.shutdown(), new Promise<void>((r) => setTimeout(r, 5_000))]);
79+
kernel = undefined;
80+
});
81+
82+
describe('#4073 — registerStandardEndpoints is not uniformly duplicate supply', () => {
83+
for (const withRest of [true, false]) {
84+
const label = withRest ? 'REST + dispatcher' : 'dispatcher only';
85+
86+
it(`${label}: with the flag ON, /data/:object is served and gated`, async () => {
87+
const booted = await boot(true, withRest);
88+
kernel = booted.kernel;
89+
const res = await fetch(`${booted.baseUrl}/api/v1/data/account`);
90+
// 401 — the anonymous-deny gate (#2567) fired, which proves the route
91+
// is MOUNTED. A 404 would mean nothing is serving it.
92+
expect(res.status, 'flag ON must mount /data/:object').toBe(401);
93+
}, 30_000);
94+
95+
it(`${label}: with the flag OFF, /data/:object 404s — nothing else picks it up`, async () => {
96+
const booted = await boot(false, withRest);
97+
kernel = booted.kernel;
98+
const res = await fetch(`${booted.baseUrl}/api/v1/data/account`);
99+
expect(
100+
res.status,
101+
'if this is no longer 404, another plugin now serves /data — re-read #4073, the flip may be safe',
102+
).toBe(404);
103+
}, 30_000);
104+
}
105+
106+
/**
107+
* The half of the surface that IS safe to retire: discovery cedes by an
108+
* explicit `hasPlugin` check, so the dispatcher's computed payload answers
109+
* whether the flag is on or off.
110+
*/
111+
for (const flag of [true, false]) {
112+
it(`discovery is the dispatcher's either way — flag ${flag ? 'ON' : 'OFF'} (#4018 cede)`, async () => {
113+
const booted = await boot(flag, false);
114+
kernel = booted.kernel;
115+
const res = await fetch(`${booted.baseUrl}/api/v1/discovery`);
116+
expect(res.status).toBe(200);
117+
const body = await res.json();
118+
// `data.routes` is the dispatcher's shape; the flag's own payload is
119+
// a flat `{ version, apiName, routes, capabilities }`.
120+
expect(body?.data?.routes, 'the dispatcher must own discovery').toBeTruthy();
121+
}, 30_000);
122+
}
123+
});

0 commit comments

Comments
 (0)