Skip to content

Commit e1b7868

Browse files
os-zhuangclaude
andcommitted
feat(rest): unify request→environment resolution on the kernel-resolver seam — ADR-0076 D11 step ④ (#2462)
The REST server ran its own parallel hostname/X-Environment-Id chain (hand-copied inline in resolveRequestEnvironmentId, resolveI18nService and computeExecCtx) while the HTTP dispatcher resolves the same question through the host-injected ADR-0006 kernel-resolver seam — the same unscoped request could be attributed to different environments depending on which HTTP surface served it. - New RestRequestEnvResolver seam; RestApiPlugin adapts the host's `kernel-resolver` service into it (cloud registers that service next to `env-registry` already — zero cloud-side change). - resolveRequestEnvironmentId is now THE single entry point; the two inline copies collapse into it. - Resolver verdicts are final (undefined = deliberately unscoped); only a throw degrades to the legacy chain. Legacy chain unchanged for OSS single-environment boots. Verified: 9 new seam-contract tests, rest 348 tests green, http-conformance 41 cross-adapter assertions green, DTS build of the 11-package dependent closure green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 85e1e4e commit e1b7868

4 files changed

Lines changed: 421 additions & 56 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
feat(rest): unify request→environment resolution on the host's `kernel-resolver` seam — ADR-0076 D11 step ④ (#2462)
6+
7+
The REST server kept its own parallel hostname/`X-Environment-Id` resolution
8+
chain (duplicated inline in three places), while the HTTP dispatcher resolves
9+
the same question through the host-injected ADR-0006 `kernel-resolver` seam —
10+
so the same unscoped request could be attributed to different environments
11+
depending on which HTTP surface served it.
12+
13+
`RestApiPlugin` now adapts the host's `kernel-resolver` service (registered by
14+
the cloud runtime next to `env-registry`; no cloud-side change needed) into a
15+
new `RestRequestEnvResolver` seam, and `resolveRequestEnvironmentId` becomes
16+
the single entry point every per-environment decision (protocol, i18n,
17+
exec-ctx) flows through. Where a resolver is wired, its answer — including the
18+
session-driven fallbacks the REST chain never had — is final; the legacy
19+
built-in chain remains for OSS single-environment boots (no resolver
20+
registered) and as the degradation path if the resolver throws.

packages/rest/src/rest-api-plugin.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
4-
import { RestServer, RestKernelManager, RestProtocol } from './rest-server.js';
4+
import { RestServer, RestKernelManager, RestProtocol, RestRequestEnvResolver } from './rest-server.js';
55
import { RestServerConfig } from '@objectstack/spec/api';
66
import { registerPackageRoutes } from './package-routes.js';
77
import { registerExternalDatasourceRoutes } from './external-datasource-routes.js';
@@ -96,6 +96,44 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
9696
// Not running in runtime/multi-environment mode — fine.
9797
}
9898

99+
// ADR-0076 D11 step ④ — request→environment resolution unified on
100+
// the host's ADR-0006 `kernel-resolver` seam. When the host
101+
// registers one (cloud runtime does, next to `env-registry`), the
102+
// REST server resolves a request's environment through the SAME
103+
// strategy instance the HTTP dispatcher uses — session fallbacks
104+
// included — instead of its own parallel hostname/header chain.
105+
// The legacy chain remains as the fallback when no resolver is
106+
// registered (OSS single-environment boots) or the resolver throws.
107+
let requestEnvResolver: RestRequestEnvResolver | undefined;
108+
try {
109+
const kernelResolver = ctx.getService<any>('kernel-resolver');
110+
if (kernelResolver && typeof kernelResolver.resolveKernel === 'function') {
111+
// The resolver's session/default-project fallback levels
112+
// resolve services from its `defaultKernel` argument —
113+
// bind the hosting kernel's service surface. `getService`
114+
// may throw on a missing service; the resolver handles
115+
// that itself.
116+
const hostKernelFacade = {
117+
getService: (name: string) => ctx.getService(name),
118+
getServiceAsync: async (name: string) => ctx.getService(name),
119+
};
120+
requestEnvResolver = {
121+
async resolveRequestEnvironmentId(req: unknown): Promise<string | undefined> {
122+
// No `routePath` hint: the REST consumers of this
123+
// seam are all data-plane routes, never the
124+
// resolver's control-plane skip prefixes. If a
125+
// resolver strategy starts keying off routePath,
126+
// add prefix-stripped assembly here.
127+
const context: { request: unknown; environmentId?: string } = { request: req };
128+
await kernelResolver.resolveKernel(context, hostKernelFacade);
129+
return context.environmentId;
130+
},
131+
};
132+
}
133+
} catch (e) {
134+
// No kernel-resolver registered — legacy chain only. Fine.
135+
}
136+
99137
// Optional default-project provider — registered by
100138
// `createSingleEnvironmentPlugin` in single-environment local mode.
101139
// Lets RestServer route bare `/api/v1/data/...` URLs into the
@@ -219,7 +257,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
219257
try { return ctx.getService<any>(name) != null; } catch { return false; }
220258
};
221259
try {
222-
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider);
260+
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver);
223261
restServer.registerRoutes();
224262

225263
ctx.logger.info('REST API successfully registered');
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0076 D11 step ④ (#2462) — request→environment resolution unified on the
5+
* host's ADR-0006 `kernel-resolver` seam.
6+
*
7+
* Locks the resolution-chain contract of `resolveRequestEnvironmentId`:
8+
* explicit id → host-injected RestRequestEnvResolver (normal return is
9+
* FINAL, throw degrades) → legacy hostname/header chain → single-project
10+
* default — and the RestApiPlugin adapter that binds the host's
11+
* `kernel-resolver` service into that seam.
12+
*/
13+
14+
import { describe, it, expect, vi } from 'vitest';
15+
import { RestServer, RestRequestEnvResolver, RestEnvRegistry } from './rest-server';
16+
import { createRestApiPlugin } from './rest-api-plugin';
17+
18+
// ---------------------------------------------------------------------------
19+
// Mocks & Helpers
20+
// ---------------------------------------------------------------------------
21+
22+
function createMockServer() {
23+
return {
24+
get: vi.fn(),
25+
post: vi.fn(),
26+
put: vi.fn(),
27+
delete: vi.fn(),
28+
patch: vi.fn(),
29+
use: vi.fn(),
30+
listen: vi.fn().mockResolvedValue(undefined),
31+
close: vi.fn().mockResolvedValue(undefined),
32+
};
33+
}
34+
35+
function createMockProtocol() {
36+
return {
37+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }),
38+
getMetaTypes: vi.fn().mockResolvedValue([]),
39+
getMetaItems: vi.fn().mockResolvedValue([]),
40+
getMetaItem: vi.fn().mockResolvedValue({}),
41+
findData: vi.fn().mockResolvedValue([]),
42+
getData: vi.fn().mockResolvedValue({}),
43+
createData: vi.fn().mockResolvedValue({ id: '1' }),
44+
updateData: vi.fn().mockResolvedValue({}),
45+
deleteData: vi.fn().mockResolvedValue({ success: true }),
46+
};
47+
}
48+
49+
const ANON_API = { api: { requireAuth: false } };
50+
51+
/** Node-style request with a Host header + optional X-Environment-Id. */
52+
function mockReq(headers: Record<string, string> = {}): any {
53+
return { headers: { host: 'tenant-a.example.com', ...headers }, url: '/api/v1/data/account' };
54+
}
55+
56+
type RestServerArgs = {
57+
envRegistry?: RestEnvRegistry;
58+
defaultEnvironmentIdProvider?: () => string | undefined;
59+
requestEnvResolver?: RestRequestEnvResolver;
60+
kernelManager?: { getOrCreate: (id: string) => Promise<any> };
61+
};
62+
63+
/** Build a RestServer with only the seams under test wired. */
64+
function buildRest(args: RestServerArgs = {}) {
65+
const server = createMockServer();
66+
const protocol = createMockProtocol();
67+
const kernelManager =
68+
args.kernelManager ??
69+
({
70+
getOrCreate: vi.fn().mockResolvedValue({
71+
getServiceAsync: vi.fn().mockResolvedValue(undefined),
72+
}),
73+
} as any);
74+
const rest = new RestServer(
75+
server as any,
76+
protocol as any,
77+
ANON_API as any,
78+
kernelManager as any,
79+
args.envRegistry,
80+
args.defaultEnvironmentIdProvider,
81+
undefined, // authServiceProvider
82+
undefined, // objectQLProvider
83+
undefined, // emailServiceProvider
84+
undefined, // sharingServiceProvider
85+
undefined, // reportsServiceProvider
86+
undefined, // approvalsServiceProvider
87+
undefined, // sharingRulesServiceProvider
88+
undefined, // i18nServiceProvider
89+
undefined, // analyticsServiceProvider
90+
undefined, // settingsServiceProvider
91+
undefined, // serviceExistsProvider
92+
undefined, // securityServiceProvider
93+
args.requestEnvResolver,
94+
);
95+
const resolve = (environmentId?: string, req?: any): Promise<string | undefined> =>
96+
(rest as any).resolveRequestEnvironmentId(environmentId, req);
97+
return { rest, server, protocol, kernelManager, resolve };
98+
}
99+
100+
/** Legacy registry that resolves every hostname to `legacy-env`. */
101+
function legacyRegistry(): RestEnvRegistry & { resolveByHostname: ReturnType<typeof vi.fn> } {
102+
return {
103+
resolveByHostname: vi.fn().mockResolvedValue({ environmentId: 'legacy-env' }),
104+
resolveById: vi.fn().mockResolvedValue({}),
105+
} as any;
106+
}
107+
108+
// ---------------------------------------------------------------------------
109+
// Resolution-chain contract
110+
// ---------------------------------------------------------------------------
111+
112+
describe('resolveRequestEnvironmentId (D11④ seam)', () => {
113+
it('returns an explicit environmentId without consulting any resolver', async () => {
114+
const resolver: RestRequestEnvResolver = {
115+
resolveRequestEnvironmentId: vi.fn().mockResolvedValue('resolver-env'),
116+
};
117+
const registry = legacyRegistry();
118+
const { resolve } = buildRest({ requestEnvResolver: resolver, envRegistry: registry });
119+
120+
await expect(resolve('explicit-env', mockReq())).resolves.toBe('explicit-env');
121+
expect(resolver.resolveRequestEnvironmentId).not.toHaveBeenCalled();
122+
expect(registry.resolveByHostname).not.toHaveBeenCalled();
123+
});
124+
125+
it('prefers the injected resolver over the legacy envRegistry chain', async () => {
126+
const resolver: RestRequestEnvResolver = {
127+
resolveRequestEnvironmentId: vi.fn().mockResolvedValue('resolver-env'),
128+
};
129+
const registry = legacyRegistry();
130+
const { resolve } = buildRest({ requestEnvResolver: resolver, envRegistry: registry });
131+
132+
await expect(resolve(undefined, mockReq())).resolves.toBe('resolver-env');
133+
// The legacy chain must not even be consulted — one authority per host.
134+
expect(registry.resolveByHostname).not.toHaveBeenCalled();
135+
});
136+
137+
it("treats the resolver's undefined as FINAL — legacy chain and default do not second-guess it", async () => {
138+
const resolver: RestRequestEnvResolver = {
139+
resolveRequestEnvironmentId: vi.fn().mockResolvedValue(undefined),
140+
};
141+
const registry = legacyRegistry();
142+
const { resolve } = buildRest({
143+
requestEnvResolver: resolver,
144+
envRegistry: registry,
145+
defaultEnvironmentIdProvider: () => 'default-env',
146+
});
147+
148+
// Both fallbacks COULD produce an id; the resolver's verdict wins anyway
149+
// (e.g. it deliberately skipped a control-plane route).
150+
await expect(resolve(undefined, mockReq())).resolves.toBeUndefined();
151+
expect(registry.resolveByHostname).not.toHaveBeenCalled();
152+
});
153+
154+
it('degrades to the legacy chain when the resolver throws', async () => {
155+
const resolver: RestRequestEnvResolver = {
156+
resolveRequestEnvironmentId: vi.fn().mockRejectedValue(new Error('resolver down')),
157+
};
158+
const registry = legacyRegistry();
159+
const { resolve } = buildRest({ requestEnvResolver: resolver, envRegistry: registry });
160+
161+
await expect(resolve(undefined, mockReq())).resolves.toBe('legacy-env');
162+
});
163+
164+
it('runs the legacy hostname chain unchanged when no resolver is injected', async () => {
165+
const registry = legacyRegistry();
166+
const { resolve } = buildRest({ envRegistry: registry });
167+
168+
await expect(resolve(undefined, mockReq())).resolves.toBe('legacy-env');
169+
expect(registry.resolveByHostname).toHaveBeenCalledWith('tenant-a.example.com');
170+
});
171+
172+
it('falls back to X-Environment-Id header, then the single-project default, when hostname misses', async () => {
173+
const registry: RestEnvRegistry = {
174+
resolveByHostname: vi.fn().mockResolvedValue(null),
175+
resolveById: vi.fn().mockImplementation(async (id: string) => (id === 'header-env' ? {} : null)),
176+
};
177+
const { resolve } = buildRest({
178+
envRegistry: registry,
179+
defaultEnvironmentIdProvider: () => 'default-env',
180+
});
181+
182+
await expect(
183+
resolve(undefined, mockReq({ 'x-environment-id': 'header-env' })),
184+
).resolves.toBe('header-env');
185+
await expect(resolve(undefined, mockReq())).resolves.toBe('default-env');
186+
});
187+
188+
it('routes resolver-provided environments into kernelManager.getOrCreate via resolveProtocol', async () => {
189+
const resolver: RestRequestEnvResolver = {
190+
resolveRequestEnvironmentId: vi.fn().mockResolvedValue('resolver-env'),
191+
};
192+
const perEnvProtocol = createMockProtocol();
193+
const kernelManager = {
194+
getOrCreate: vi.fn().mockResolvedValue({
195+
getServiceAsync: vi.fn().mockResolvedValue(perEnvProtocol),
196+
}),
197+
};
198+
const { rest } = buildRest({ requestEnvResolver: resolver, kernelManager });
199+
200+
const resolved = await (rest as any).resolveProtocol(undefined, mockReq());
201+
expect(kernelManager.getOrCreate).toHaveBeenCalledWith('resolver-env');
202+
expect(resolved).toBe(perEnvProtocol);
203+
});
204+
});
205+
206+
// ---------------------------------------------------------------------------
207+
// RestApiPlugin adapter — binds the host's `kernel-resolver` service
208+
// ---------------------------------------------------------------------------
209+
210+
describe('RestApiPlugin kernel-resolver adapter (D11④)', () => {
211+
function createMockPluginContext(services: Record<string, any>) {
212+
return {
213+
registerService: vi.fn(),
214+
getService: vi.fn((name: string) => {
215+
if (services[name]) return services[name];
216+
throw new Error(`Service '${name}' not found`);
217+
}),
218+
getServices: vi.fn(() => new Map(Object.entries(services))),
219+
hook: vi.fn(),
220+
trigger: vi.fn().mockResolvedValue(undefined),
221+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
222+
getKernel: vi.fn(),
223+
};
224+
}
225+
226+
/** Base services every plugin boot needs. */
227+
function baseServices() {
228+
return {
229+
'http.server': createMockServer(),
230+
protocol: createMockProtocol(),
231+
objectql: { registerObject: vi.fn(), find: vi.fn().mockResolvedValue([]) },
232+
};
233+
}
234+
235+
it('wires the kernel-resolver service into the REST env seam (context.environmentId read-back)', async () => {
236+
const resolveKernel = vi.fn().mockImplementation(async (context: any) => {
237+
context.environmentId = 'cloud-env';
238+
return undefined;
239+
});
240+
const services: Record<string, any> = {
241+
...baseServices(),
242+
'kernel-resolver': { resolveKernel },
243+
'kernel-manager': {
244+
getOrCreate: vi.fn().mockResolvedValue({
245+
getServiceAsync: vi.fn().mockResolvedValue(undefined),
246+
}),
247+
},
248+
};
249+
const ctx = createMockPluginContext(services);
250+
const plugin = createRestApiPlugin({ api: ANON_API as any });
251+
await plugin.init?.(ctx as any);
252+
await (plugin as any).start(ctx as any);
253+
254+
// Pull the registered GET /api/v1/data/:object handler and drive one
255+
// unscoped request through it — the adapter must consult resolveKernel.
256+
const server = services['http.server'];
257+
expect(server.get.mock.calls.map((c: any[]) => c[0])).toContain('/api/v1/data/:object');
258+
const listRoute = server.get.mock.calls.find((c: any[]) => c[0] === '/api/v1/data/:object');
259+
const handler = listRoute![1];
260+
const res = {
261+
json: vi.fn(),
262+
status: vi.fn().mockReturnThis(),
263+
send: vi.fn(),
264+
setHeader: vi.fn(),
265+
headersSent: false,
266+
};
267+
await handler({ params: { object: 'account' }, query: {}, headers: { host: 'x.example.com' } }, res);
268+
269+
expect(resolveKernel).toHaveBeenCalled();
270+
const [context, hostKernel] = resolveKernel.mock.calls[0];
271+
expect(context.request).toBeDefined();
272+
// The facade must expose the hosting kernel's service surface (the
273+
// resolver's session/default-project levels resolve services off it).
274+
expect(hostKernel.getService('protocol')).toBe(services.protocol);
275+
await expect(hostKernel.getServiceAsync('protocol')).resolves.toBe(services.protocol);
276+
// The resolver's answer must reach kernelManager.getOrCreate — the REST
277+
// request is served from the SAME environment the dispatcher would pick.
278+
expect(services['kernel-manager'].getOrCreate).toHaveBeenCalledWith('cloud-env');
279+
});
280+
281+
it('boots and serves without a kernel-resolver service (OSS single-environment mode)', async () => {
282+
const services: Record<string, any> = { ...baseServices() };
283+
const ctx = createMockPluginContext(services);
284+
const plugin = createRestApiPlugin({ api: ANON_API as any });
285+
await plugin.init?.(ctx as any);
286+
await (plugin as any).start(ctx as any);
287+
288+
const server = services['http.server'];
289+
const listRoute = server.get.mock.calls.find((c: any[]) => c[0] === '/api/v1/data/:object');
290+
expect(listRoute).toBeDefined();
291+
const res = {
292+
json: vi.fn(),
293+
status: vi.fn().mockReturnThis(),
294+
send: vi.fn(),
295+
setHeader: vi.fn(),
296+
headersSent: false,
297+
};
298+
await listRoute![1]({ params: { object: 'account' }, query: {}, headers: {} }, res);
299+
// Served by the boot-time control protocol — no resolver, no crash.
300+
expect(services.protocol.findData).toHaveBeenCalled();
301+
});
302+
});

0 commit comments

Comments
 (0)