Skip to content

Commit 1378ec7

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(rest): the environment-scoped ?layers=true successor Link names the request path, not the route template (#20508) (#20528)
Fixes #20508 Clause-②: no ## What was wrong On `RestServer`'s environment-scoped mount (`api.enableProjectScoping`), the deprecated `?layers=true` spelling of the item read answered `Deprecation: true` and a successor-version `Link` built from `metaPath`. On that mount `metaPath` is the route template, so the `Link` named the path `/api/v1/environments/:environmentId/meta/view/lead_all/layers`, with a literal `:environmentId` in it. A client that followed the header requested that path. ## What changed `packages/rest/src/rest-server.ts`, the `?layers=true` call site of `metaItemLayersDeprecationHeaders` only. `RestServer` now passes the request's own path (`IHttpRequest.path`), parsed as a URL path the way the runtime dispatcher's `requestedItemPath` reads its request URL: without the trailing slash. The shared helper in `meta-item-read-gate.ts` is untouched (no template replace anywhere). The parse matters for one measured reason: the Hono adapter hands handlers a `decodeURI`'d path (`c.req.path`). A name requested as `lead%20all` reaches the handler as `lead all`. The parse percent-encodes it again, so the `Link` stays a valid URI reference. A request that carries no path gets `Deprecation: true` alone, which is what the helper prescribes for a transport that cannot say where it serves the item. Pins (triage grade `5879492075`): - the scoped answer's `Link` names `/api/v1/environments/env_1/meta/view/lead_all/layers`; - the unscoped control's `Link` still names `/api/v1/meta/view/lead_all/layers`. Both pins run through the real `HonoHttpServer` (`packages/rest/src/meta-item-layers-deprecation-link.test.ts`), because the path comes from the adapter. The same file pins the encoded-name case. The mock-harness file `meta-item-layered-route.test.ts` now passes the `path` field that `IHttpRequest` declares required (its hand-built request omitted it). It also pins the no-path branch. ## Measurements (PM mechanism hypotheses) All at `fb386074f5` (unfixed) or `439dd81bd3` (fixed), through the real `HonoHttpServer` with `enableProjectScoping: true, projectResolution: 'optional'`. - **H0: confirmed.** Unfixed, `GET /api/v1/environments/env_1/meta/view/lead_all?layers=true` answered `200`, `deprecation=true`, with a `Link` naming `/api/v1/environments/:environmentId/meta/view/lead_all/layers`. The unscoped control `GET /api/v1/meta/view/lead_all?layers=true` named `/api/v1/meta/view/lead_all/layers`, which is correct. - **H1: half confirmed.** The request's own path is available at the call site as `req.path`. The Hono adapter sets it from `c.req.path`, and the Node conformance port from `url.pathname`. It does **not** carry a percent-encoded name unchanged under Hono: `c.req.path` is `decodeURI`'d. A probe route read the following for the scoped path: | requested name | `req.path` segment | after the parse | dispatcher's `requestedItemPath` | |---|---|---|---| | `lead_all` | `lead_all` | `lead_all` | `lead_all` | | `lead%20all` | `lead all` | `lead%20all` | `lead%20all` | | `lead%2Fall` | `lead%2Fall` | `lead%2Fall` | `lead%2Fall` | | `lead%25all` | `lead%25all` | `lead%25all` | `lead%25all` | | `l%C3%A9ad` | `léad` | `l%C3%A9ad` | `l%C3%A9ad` | | `lead%5Fall` | `lead_all` | `lead_all` | `lead%5Fall` | | env `env%201` | `env 1` | `env%201` | `env%201` | After the parse, six of the seven rows match the dispatcher byte for byte. The seventh is `%5F`, a percent-encoded unreserved character, which Hono decodes to `_`. The two spellings are equivalent under RFC 3986 section 6.2.2.2, and they name the same route. - **H2 (ablation): the red set is larger than predicted.** The mutation put back the template argument (`${metaPath}/${req.params.type}/${req.params.name}`) through `scripts/ablation-replace.mjs`, which confirmed it landed (anchor 1 to 0, blob `cb57de8250e2` to `004a11651dba`). The result was 3 red and 13 green of 16: the scoped pin, the scoped encoded-name pin, and the mock no-path pin. The PM predicted the scoped pin alone. The two extra reds are pins this PR adds, and each is a scoped or no-path case the template argument gets wrong. Both unscoped controls (Hono and mock) stayed green. Restore was proven: blob after restore `cb57de8250e2` equals the blob at `HEAD`, and `git diff HEAD` was empty. - **Second ablation, of the parse.** It replaced the parse with the raw `req.path`. The first attempt was a no-op: the tool refused because the replacement text `requestPath` already occurred inside the anchor, so the count did not rise, and it restored without running the tests. The re-run used a unique marker and landed (blob `cb57de8250e2` to `48fa04adf547`). Exactly 1 test went red: the encoded-name pin, which received a raw space in `lead all/layers`. Restore was proven the same way. ## Verification At `439dd81bd3`: - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: 222 files, 4235 passed, 40 skipped. - `pnpm --filter @objectstack/rest typecheck`: exit 0, which includes `check:test-typecheck`. `tsc -p tsconfig.test.json --listFiles` lists both edited test files. - `pnpm lint` (full repo, not narrowed): exit 0. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 62 commands. 60 exited 0. Two exited 3 with `PREREQUISITE NOT MET`, because both need the whole workspace built: `check:dual-build-cjs-loads` and `check:type-check-debt`. Those two are **NOT MEASURED**. The whole-workspace build was not run on this shared host. `--ran` reconciliation: 62 derived, 60 run, 2 NOT-MEASURED, 0 UNRUN. - Roster gates whose roster lives under a changed directory: `check-changeset-fixed`, `check:error-code-casing` and `check:filter-alias-parity` all exited 0. - `node scripts/check-issue-citations.mjs --base origin/main`, after merging `origin/main` (already up to date at `fb386074f5`): exit 0. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build pnpm --filter @objectstack/rest build pnpm --filter @objectstack/rest typecheck pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/meta-item-layers-deprecation-link.test.ts src/meta-item-layered-route.test.ts ## Acceptance notes - **The unscoped answer changes for encoded names, and only for them.** Unfixed, the unscoped `Link` was assembled from the decoded route parameters. Measured on `fb386074f5` through Hono: `lead%2Fall` named `/api/v1/meta/view/lead/all/layers`, a different path; `lead%25all` named `lead%all`, an invalid percent-encoding; `lead%20all` and `l%C3%A9ad` put a raw space and a raw non-ASCII character in the header. After the fix, each keeps its encoding. For every name that needs no encoding, the unscoped `Link` is byte-identical. The changeset says so. - **The path parse now exists twice.** Once is the runtime dispatcher's `requestedItemPath` in `packages/runtime/src/domains/meta.ts`, and once is inline at this call site. Moving one copy into the shared seam, `meta-item-read-gate.ts`, is outside this claim's file surface: that file and `packages/runtime/**` are read-only here, and PR #20527 is editing the seam. carrier: none. Noted, not filed. - **Both transports name the successor from the path their adapter reports.** A host that mounts the app under a parent that strips a prefix would name the inner path, on both transports alike. NOT MEASURED; no such host was composed here. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0368a33 commit 1378ec7

4 files changed

Lines changed: 170 additions & 3 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): the environment-scoped `?layers=true` answer's successor `Link` names the path the request used, not the route template (#20508)
6+
7+
Clause-②: no
8+
9+
On `RestServer`'s environment-scoped mount (`api.enableProjectScoping`),
10+
`GET /api/v1/environments/env_1/meta/view/lead_all?layers=true` answered its
11+
`Deprecation` header with a successor `Link` naming
12+
`/api/v1/environments/:environmentId/meta/view/lead_all/layers`: the route
13+
template, with a literal `:environmentId` in it. A client that followed the
14+
header requested that path. The `Link` now names
15+
`/api/v1/environments/env_1/meta/view/lead_all/layers`.
16+
17+
`RestServer` builds the `Link` from the request's own path, read the way the
18+
runtime dispatcher reads its request URL, so both transports name the successor
19+
the same way. The unscoped mount's `Link` is unchanged for every name that needs
20+
no percent-encoding. A percent-encoded name now stays encoded in the `Link`
21+
(`lead%20all`, where the header used to carry a raw space), because the path is
22+
parsed as a URL path instead of being assembled from decoded route parameters.
23+
A request that carries no path of its own is still answered `Deprecation: true`,
24+
and names no successor. The body, the status and the `Deprecation` header are
25+
unchanged on both mounts.

‎packages/rest/src/meta-item-layered-route.test.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,19 @@ function routeFor(rest: RestServer, path: string) {
9898
return (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path);
9999
}
100100

101-
async function dispatch(protocol: any, path: string, params: any, query: any = {}) {
101+
/**
102+
* `requestPath` is the request's own path (`IHttpRequest.path`), which the
103+
* deprecated spelling's successor `Link` is built from (#20508). Omitted, the
104+
* request carries none, as a request built by hand may.
105+
*/
106+
async function dispatch(protocol: any, path: string, params: any, query: any = {}, requestPath?: string) {
102107
const rest = new RestServer(mockServer() as any, protocol as any, ANON_API as any);
103108
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] });
104109
rest.registerRoutes();
105110
const route = routeFor(rest, path);
106111
if (!route) throw new Error(`route not registered: GET ${path}`);
107112
const res = mockRes();
108-
await route.handler({ params, query, headers: {} }, res);
113+
await route.handler({ params, query, headers: {}, ...(requestPath === undefined ? {} : { path: requestPath }) }, res);
109114
return { res, body: res.json.mock.calls.at(-1)?.[0] };
110115
}
111116

@@ -204,13 +209,25 @@ describe('#5882 `?layers=true` — the deprecation window', () => {
204209
it('advertises the successor path in machine-readable headers', async () => {
205210
const { res } = await dispatch(
206211
baseProtocol(), ITEM_PATH, { type: 'object', name: 'customer' }, { layers: 'true' },
212+
'/api/v1/meta/object/customer',
207213
);
208214
expect(res.header).toHaveBeenCalledWith('Deprecation', 'true');
209215
expect(res.headers.Link).toBe(
210216
'</api/v1/meta/object/customer/layers>; rel="successor-version"',
211217
);
212218
});
213219

220+
it('names no successor when the request carries no path of its own (#20508)', async () => {
221+
// The `Link` is built from the path the request arrived on, never from
222+
// the route template. A request with none is still deprecated, and says
223+
// so, but advertises no successor it cannot locate.
224+
const { res } = await dispatch(
225+
baseProtocol(), ITEM_PATH, { type: 'object', name: 'customer' }, { layers: 'true' },
226+
);
227+
expect(res.header).toHaveBeenCalledWith('Deprecation', 'true');
228+
expect(res.headers).not.toHaveProperty('Link');
229+
});
230+
214231
it('does not mark the ordinary read deprecated', async () => {
215232
const { res } = await dispatch(baseProtocol(), ITEM_PATH, { type: 'object', name: 'customer' });
216233
expect(res.header).not.toHaveBeenCalledWith('Deprecation', 'true');
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20508] The deprecated `?layers=` spelling's successor `Link` names the path
5+
* the request arrived on, on BOTH `RestServer` mounts.
6+
*
7+
* `RestServer` built the `Link` from `metaPath`, which on the
8+
* environment-scoped mount is the route TEMPLATE:
9+
* `GET /api/v1/environments/env_1/meta/view/lead_all?layers=true` answered
10+
* `</api/v1/environments/:environmentId/meta/view/lead_all/layers>`, a path with
11+
* a literal `:environmentId` in it, so a client that followed the header asked
12+
* for a path it could not mean. The runtime dispatcher builds its `Link` from
13+
* the request's own URL; `RestServer` now passes the request's own path
14+
* (`IHttpRequest.path`), read the same way.
15+
*
16+
* Driven through the real `HonoHttpServer`, the adapter `os serve` mounts,
17+
* because the path is the ADAPTER's statement: a hand-built request carries
18+
* whatever the test writes into it, and the encoded-name case below turns on
19+
* what this adapter hands over (a `decodeURI`'d path).
20+
*/
21+
22+
import { describe, it, expect, vi } from 'vitest';
23+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
24+
import { RestServer } from './rest-server';
25+
26+
/** Both mounts: `optional` registers the unscoped routes beside the scoped ones. */
27+
const SCOPED_API = { api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'optional' } };
28+
29+
function protocol() {
30+
return {
31+
getDiscovery: vi.fn().mockResolvedValue({
32+
version: 'v0',
33+
routes: { data: '', metadata: '', ui: '', auth: '/auth' },
34+
}),
35+
getMetaTypes: vi.fn().mockResolvedValue([]),
36+
getMetaItems: vi.fn().mockResolvedValue([]),
37+
getMetaItem: vi.fn(async ({ type, name }: any) => ({
38+
type, name, item: { name }, lock: 'none', editable: true, deletable: true, resettable: false,
39+
})),
40+
getMetaItemLayered: vi.fn(async ({ type, name }: any) => ({
41+
type,
42+
name,
43+
code: { name },
44+
overlay: null,
45+
overlayScope: 'org',
46+
effective: { name },
47+
_diagnostics: { valid: true },
48+
lock: 'none',
49+
editable: true,
50+
deletable: true,
51+
resettable: false,
52+
})),
53+
findData: vi.fn().mockResolvedValue([]),
54+
};
55+
}
56+
57+
async function layersFlagRead(path: string) {
58+
const server = new HonoHttpServer(0);
59+
const p = protocol();
60+
const rest = new RestServer(server as any, p as any, SCOPED_API as any);
61+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] });
62+
rest.registerRoutes();
63+
const res: Response = await server.getRawApp().fetch(new Request(`http://local${path}?layers=true`));
64+
return {
65+
status: res.status,
66+
deprecation: res.headers.get('deprecation'),
67+
link: res.headers.get('link'),
68+
layeredReads: p.getMetaItemLayered.mock.calls.length,
69+
};
70+
}
71+
72+
describe('[#20508] the `?layers=true` successor `Link` names the request path', () => {
73+
it('on the environment-scoped mount, names the environment the request named', async () => {
74+
const answer = await layersFlagRead('/api/v1/environments/env_1/meta/view/lead_all');
75+
76+
// Anti-vacuity: the flag was served the layered view, so the headers
77+
// below are that branch's, not an error answer's.
78+
expect(answer.status).toBe(200);
79+
expect(answer.layeredReads).toBe(1);
80+
expect(answer.deprecation).toBe('true');
81+
expect(answer.link).toBe(
82+
'</api/v1/environments/env_1/meta/view/lead_all/layers>; rel="successor-version"',
83+
);
84+
});
85+
86+
it('on the unscoped mount (the control), is unchanged', async () => {
87+
const answer = await layersFlagRead('/api/v1/meta/view/lead_all');
88+
89+
expect(answer.status).toBe(200);
90+
expect(answer.layeredReads).toBe(1);
91+
expect(answer.deprecation).toBe('true');
92+
expect(answer.link).toBe('</api/v1/meta/view/lead_all/layers>; rel="successor-version"');
93+
});
94+
95+
it('keeps a percent-encoded name encoded, though the adapter hands the path over decoded', async () => {
96+
// Hono's `c.req.path` is `decodeURI`'d: `lead%20all` reaches the handler
97+
// as `lead all`. Written into the header as it arrived, that is a space
98+
// inside a URI reference; parsed as a URL path, as the dispatcher parses
99+
// its request URL, it is `lead%20all` again.
100+
const answer = await layersFlagRead('/api/v1/environments/env_1/meta/view/lead%20all');
101+
102+
expect(answer.status).toBe(200);
103+
expect(answer.link).toBe(
104+
'</api/v1/environments/env_1/meta/view/lead%20all/layers>; rel="successor-version"',
105+
);
106+
});
107+
});

‎packages/rest/src/rest-server.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6295,10 +6295,28 @@ export class RestServer {
62956295
// `Deprecation` + RFC 8288 `Link` to the successor) are the
62966296
// ones the runtime dispatcher's item read asks too, so the
62976297
// deprecated spelling is one answer on both transports.
6298+
//
6299+
// [#20508] The `Link` names the path THIS request arrived
6300+
// on (`IHttpRequest.path`), read the way the dispatcher
6301+
// reads its request URL (`requestedItemPath`, runtime
6302+
// `domains/meta.ts`): parsed as a URL path, without its
6303+
// trailing slash. ⛔ Never `metaPath` — on the
6304+
// environment-scoped mount that is the route TEMPLATE, and
6305+
// the successor read `/environments/:environmentId/…/layers`,
6306+
// a path no client can request. The parse is what keeps the
6307+
// path a valid URI reference: the Hono adapter hands over a
6308+
// `decodeURI`'d path (`lead%20all` arrives as `lead all`),
6309+
// and the parse percent-encodes it again. A request with no
6310+
// path names no successor: `Deprecation` alone, as the
6311+
// helper prescribes for a transport that cannot say where
6312+
// it serves the item.
62986313
const wantLayered = metaReadGate.wantsMetaItemLayers(req.query);
62996314
if (wantLayered && typeof (p as any).getMetaItemLayered === 'function') {
6315+
const requestPath: unknown = req.path;
63006316
const deprecation = metaReadGate.metaItemLayersDeprecationHeaders(
6301-
`${metaPath}/${req.params.type}/${req.params.name}`,
6317+
typeof requestPath === 'string' && requestPath.startsWith('/')
6318+
? new URL(`http://rest-server.invalid${requestPath}`).pathname.replace(/\/+$/, '') || undefined
6319+
: undefined,
63026320
);
63036321
for (const [header, value] of Object.entries(deprecation)) res.header(header, value);
63046322
await this.serveMetaItemLayered(req, res, environmentId, p, maskPosture);

0 commit comments

Comments
 (0)