Skip to content

Commit 21ca1d5

Browse files
os-zhuangclaude
andauthored
feat(runtime): extract /keys /storage /ui dispatcher domain bodies — ADR-0076 D11 step ③ PR-3 (#2462) (#3522)
Batch 3 of the per-domain decomposition, same pattern as PR-2: - domains/keys.ts carries the zero-tolerance sys_api_key mint contract (user_id pinned, body whitelisted, raw key returned once); the legacy branch's '/keys?' query-string form is reproduced with a second registry entry next to the segment match. - domains/storage.ts drops the strictly-redundant `|| this.kernel.services?.['file-storage']` leg: resolveService's fallback chain already ends at the services map, and under Map-shaped services the index access returned undefined anyway. - domains/ui.ts is a straight move. - DomainHandlerDeps grows getObjectQL (env-scoped, registry-shape checked) — needed by /keys now and /data /meta when they migrate. - Unused generateApiKey import removed from the dispatcher. Verified: seam suite 25 tests, runtime 630 green, http-conformance 41 cross-adapter assertions green, 25-package dependent closure builds with DTS (--force). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1598568 commit 21ca1d5

7 files changed

Lines changed: 365 additions & 183 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract /keys, /storage and /ui dispatcher domain bodies — ADR-0076 D11 step ③, PR-3 (#2462)
6+
7+
Continues the per-domain decomposition: three more handler bodies move out
8+
of `HttpDispatcher` into `domains/keys.ts` (incl. the zero-tolerance
9+
API-key-mint security contract), `domains/storage.ts` and `domains/ui.ts`,
10+
running on the explicit `DomainHandlerDeps` contract (extended with
11+
`getObjectQL` for the data-plane domains). The `/keys` legacy branch's
12+
`'/keys?'` query-string form is reproduced with a second registry entry;
13+
storage drops its strictly-redundant `kernel.services` index-access fallback
14+
(dead under Map-shaped services, duplicate under object-shaped ones). Thin
15+
`handleXxx` delegates remain for direct callers. Zero behavior change —
16+
locked by the 41-assertion http-conformance suite and 6 new seam tests.

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,68 @@ describe('HttpDispatcher extracted domains (PR-2)', () => {
236236
expect(result.response?.status ?? 404).not.toBe(200);
237237
});
238238
});
239+
240+
// ---------------------------------------------------------------------------
241+
// PR-3 — keys / storage / ui extraction
242+
// ---------------------------------------------------------------------------
243+
244+
describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => {
245+
it('POST /keys rejects anonymous callers with 401 (identity gate inside the extracted body)', async () => {
246+
const result = await makeDispatcher().dispatch('POST', '/keys', { name: 'k' }, {}, {} as any);
247+
expect(result.handled).toBe(true);
248+
expect(result.response?.status).toBe(401);
249+
});
250+
251+
it('GET /keys answers 405 (mint is POST-only), and /keysfoo is NOT claimed (segment semantics)', async () => {
252+
const dispatcher = makeDispatcher();
253+
const wrongMethod = await dispatcher.dispatch('GET', '/keys', undefined, {}, {} as any);
254+
expect(wrongMethod.response?.status).toBe(405);
255+
const lexical = await dispatcher.dispatch('POST', '/keysfoo', { name: 'k' }, {}, {} as any);
256+
expect(lexical.response?.status ?? 404).not.toBe(405);
257+
});
258+
259+
it('POST /keys mints a key pinned to the caller (thin delegate carries the extracted body)', async () => {
260+
const insert = vi.fn().mockResolvedValue({ id: 'key-row-1' });
261+
const objectql = {
262+
insert,
263+
find: vi.fn().mockResolvedValue([]),
264+
getObjects: vi.fn().mockReturnValue({}),
265+
registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) },
266+
};
267+
const context: any = { executionContext: { userId: 'caller-1' } };
268+
// Direct delegate call — dispatch() would re-resolve identity off the
269+
// auth-less mock kernel and overwrite the seeded executionContext.
270+
const result = await makeDispatcher({ objectql }).handleKeys('POST', { name: 'CI Key', user_id: 'attacker' }, context);
271+
expect(result.response?.status).toBe(201);
272+
const row = insert.mock.calls[0][1];
273+
expect(insert.mock.calls[0][0]).toBe('sys_api_key');
274+
// user_id pinned to caller; body's user_id ignored; only the hash stored.
275+
expect(row.user_id).toBe('caller-1');
276+
expect(row.key).not.toBe(result.response?.body?.data?.key);
277+
expect(result.response?.body?.data?.key).toBeTruthy();
278+
});
279+
280+
it('/storage responds 501 when file-storage is not configured (extracted body keeps in-handler semantics)', async () => {
281+
const result = await makeDispatcher().dispatch('POST', '/storage/upload', { blob: 1 }, {}, {} as any);
282+
expect(result.handled).toBe(true);
283+
expect(result.response?.status).toBe(501);
284+
});
285+
286+
it('/storage/upload uploads through the file-storage service', async () => {
287+
const upload = vi.fn().mockResolvedValue({ id: 'f1' });
288+
const result = await makeDispatcher({ 'file-storage': { upload, download: vi.fn() } })
289+
.dispatch('POST', '/storage/upload', { some: 'file' }, {}, {} as any);
290+
expect(result.response?.status).toBe(200);
291+
expect(upload).toHaveBeenCalledTimes(1);
292+
});
293+
294+
it('/ui/view/:object serves the protocol getUiView result; 503 without a protocol service', async () => {
295+
const getUiView = vi.fn().mockResolvedValue({ view: 'list-def' });
296+
const ok = await makeDispatcher({ protocol: { getUiView } }).dispatch('GET', '/ui/view/account/list', undefined, {}, {} as any);
297+
expect(ok.response?.status).toBe(200);
298+
expect(getUiView).toHaveBeenCalledWith({ object: 'account', type: 'list' });
299+
300+
const missing = await makeDispatcher().dispatch('GET', '/ui/view/account', undefined, {}, {} as any);
301+
expect(missing.response?.status).toBe(503);
302+
});
303+
});

packages/runtime/src/domain-handler-registry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ export interface DomainHandlerDeps {
8080
resolveService(name: string, environmentId?: string): any;
8181
/** Unscoped service lookup on the current kernel (may return a Promise). */
8282
getService(name: string): any;
83+
/**
84+
* Environment-scoped ObjectQL lookup with a registry-shape check
85+
* (resolves the `objectql` service and returns it only when it exposes
86+
* `.registry`; null otherwise). The data-plane domains (/keys today,
87+
* /data /meta when they migrate) depend on this.
88+
*/
89+
getObjectQL(environmentId?: string): Promise<any>;
8390
/** Standard success envelope. */
8491
success(data: any, meta?: any): { status: number; body: any };
8592
/** Standard error envelope. */
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/keys` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-3).
5+
*
6+
* Generates a `sys_api_key` and returns the raw secret EXACTLY ONCE
7+
* (`POST /keys`). This is the only mint path — the raw key is never stored
8+
* (only its sha256 hash) and never re-displayable.
9+
*
10+
* Security (zero-tolerance):
11+
* - Requires an authenticated principal; `user_id` is PINNED to that
12+
* caller and is NEVER read from the request body (no impersonation).
13+
* - Body is whitelisted to `name` (+ optional `expires_at`); any
14+
* `key` / `id` / `user_id` / `revoked` in the body is ignored, so a
15+
* caller cannot forge a known-secret or escalate.
16+
* - `scopes` are intentionally NOT accepted from the body in v1: the
17+
* verify path ADDS scopes to the principal's permissions, so honouring
18+
* arbitrary body scopes would be an escalation vector. A generated key
19+
* therefore acts exactly AS the caller (via `user_id` resolution).
20+
* Narrowing/scoped keys need subset-enforcement — deferred.
21+
* - The raw key and its hash never enter logs or error messages.
22+
* - The row is written with an elevated `{ isSystem: true }` context
23+
* because `sys_api_key` is protection-locked; safe because the row's
24+
* contents are fully server-controlled (user_id pinned to caller).
25+
*/
26+
27+
import { generateApiKey } from '../security/api-key.js';
28+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
29+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
30+
31+
/**
32+
* The legacy branch matched `=== '/keys' || startsWith('/keys/') ||
33+
* startsWith('/keys?')` — a segment match PLUS the query-string form some
34+
* adapters pass through in `path`. Two entries reproduce that exactly.
35+
*/
36+
export function createKeysDomains(deps: DomainHandlerDeps): DomainRoute[] {
37+
const handler: DomainRoute['handler'] = (req, context) =>
38+
handleKeysRequest(deps, req.method, req.body, context);
39+
return [
40+
{ prefix: '/keys', match: 'segment', handler },
41+
{ prefix: '/keys?', handler },
42+
];
43+
}
44+
45+
/** Body kept signature-compatible with the legacy `HttpDispatcher.handleKeys`. */
46+
export async function handleKeysRequest(
47+
deps: DomainHandlerDeps,
48+
method: string,
49+
body: any,
50+
context: HttpProtocolContext,
51+
): Promise<HttpDispatcherResult> {
52+
if (method !== 'POST') {
53+
return { handled: true, response: deps.error('Method not allowed', 405) };
54+
}
55+
56+
const ec = context.executionContext;
57+
if (!ec || !ec.userId) {
58+
return { handled: true, response: deps.error('Unauthorized: sign in to generate an API key', 401) };
59+
}
60+
61+
// ── Whitelist the body. Only `name` and optional `expires_at`. ──
62+
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
63+
const name = rawName || 'API Key';
64+
65+
let expiresAt: string | undefined;
66+
if (body?.expires_at != null && body.expires_at !== '') {
67+
const ms = typeof body.expires_at === 'number'
68+
? (body.expires_at < 1e12 ? body.expires_at * 1000 : body.expires_at)
69+
: Date.parse(String(body.expires_at));
70+
if (Number.isNaN(ms)) {
71+
return { handled: true, response: deps.error('Invalid expires_at: must be a parseable date', 400) };
72+
}
73+
if (ms <= Date.now()) {
74+
return { handled: true, response: deps.error('Invalid expires_at: must be in the future', 400) };
75+
}
76+
expiresAt = new Date(ms).toISOString();
77+
}
78+
79+
const ql = (await deps.getObjectQL(context.environmentId))
80+
?? (await deps.resolveService('objectql', context.environmentId));
81+
if (!ql || typeof ql.insert !== 'function') {
82+
return { handled: true, response: deps.error('Data service not available', 503) };
83+
}
84+
85+
// Generate AFTER validation so we never mint on a rejected request.
86+
const generated = generateApiKey();
87+
88+
// Server-controlled row. user_id is pinned to the caller; only the hash
89+
// is persisted. NOTHING from the body can set key/id/user_id/revoked.
90+
const row: Record<string, unknown> = {
91+
name,
92+
key: generated.hash,
93+
prefix: generated.prefix,
94+
user_id: ec.userId,
95+
revoked: false,
96+
};
97+
if (expiresAt) row.expires_at = expiresAt;
98+
99+
let inserted: any;
100+
try {
101+
inserted = await ql.insert('sys_api_key', row, { context: { isSystem: true } });
102+
} catch {
103+
// Never surface the underlying error (could echo row contents).
104+
return { handled: true, response: deps.error('Failed to create API key', 500) };
105+
}
106+
const id = inserted?.id ?? (Array.isArray(inserted) ? inserted[0]?.id : undefined);
107+
108+
// Raw key returned ONCE. Do not log it.
109+
return {
110+
handled: true,
111+
response: {
112+
status: 201,
113+
body: {
114+
success: true,
115+
data: {
116+
id,
117+
name,
118+
prefix: generated.prefix,
119+
key: generated.raw,
120+
...(expiresAt ? { expires_at: expiresAt } : {}),
121+
},
122+
},
123+
},
124+
};
125+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/storage` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-3).
5+
* Upload / download bridge to the `file-storage` service. Download results
6+
* may be a redirect or a stream — those come back as `result:{type:...}` for
7+
* the HTTP adapter to realize (the dispatcher envelope can't carry them).
8+
*
9+
* Routes (path is the sub-path after `/storage`):
10+
* POST /upload → upload (body is the file/stream)
11+
* GET /file/:id → download (redirect | stream | metadata)
12+
*/
13+
14+
import { CoreServiceName } from '@objectstack/spec/system';
15+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
16+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
17+
18+
export function createStorageDomain(deps: DomainHandlerDeps): DomainRoute {
19+
return {
20+
prefix: '/storage',
21+
handler: (req, context) =>
22+
handleStorageRequest(deps, req.path.substring(8), req.method, req.body, context),
23+
};
24+
}
25+
26+
/** Body kept signature-compatible with the legacy `HttpDispatcher.handleStorage`. */
27+
export async function handleStorageRequest(
28+
deps: DomainHandlerDeps,
29+
path: string,
30+
method: string,
31+
file: any,
32+
context: HttpProtocolContext,
33+
): Promise<HttpDispatcherResult> {
34+
// The legacy body had `getService(...) || this.kernel.services?.['file-storage']`.
35+
// The second leg was strictly redundant: resolveService's fallback chain
36+
// already ends at the services map (and when `services` is a Map, the
37+
// legacy index access returned undefined anyway), so it is dropped here.
38+
const storageService = await deps.getService(CoreServiceName.enum['file-storage']);
39+
if (!storageService) {
40+
return { handled: true, response: deps.error('File storage not configured', 501) };
41+
}
42+
43+
const m = method.toUpperCase();
44+
const parts = path.replace(/^\/+/, '').split('/');
45+
46+
// POST /storage/upload
47+
if (parts[0] === 'upload' && m === 'POST') {
48+
if (!file) {
49+
return { handled: true, response: deps.error('No file provided', 400) };
50+
}
51+
const result = await storageService.upload(file, { request: context.request });
52+
return { handled: true, response: deps.success(result) };
53+
}
54+
55+
// GET /storage/file/:id
56+
if (parts[0] === 'file' && parts[1] && m === 'GET') {
57+
const id = parts[1];
58+
const result = await storageService.download(id, { request: context.request });
59+
60+
// Result can be URL (redirect), Stream/Blob, or metadata
61+
if (result.url && result.redirect) {
62+
// Must be handled by adapter to do actual redirect
63+
return { handled: true, result: { type: 'redirect', url: result.url } };
64+
}
65+
66+
if (result.stream) {
67+
// Must be handled by adapter to pipe stream
68+
return {
69+
handled: true,
70+
result: {
71+
type: 'stream',
72+
stream: result.stream,
73+
headers: {
74+
'Content-Type': result.mimeType || 'application/octet-stream',
75+
'Content-Length': result.size
76+
}
77+
}
78+
};
79+
}
80+
81+
return { handled: true, response: deps.success(result) };
82+
}
83+
84+
return { handled: false };
85+
}

packages/runtime/src/domains/ui.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/ui` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-3).
5+
* Serves rendered view metadata from the `protocol` service.
6+
*
7+
* Routes (path is the sub-path after `/ui`):
8+
* GET /view/:object[/:type] → getUiView (type also accepted as ?type=)
9+
*/
10+
11+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
12+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
13+
14+
export function createUiDomain(deps: DomainHandlerDeps): DomainRoute {
15+
return {
16+
prefix: '/ui',
17+
handler: (req, context) =>
18+
handleUiRequest(deps, req.path.substring(3), req.query, context),
19+
};
20+
}
21+
22+
/** Body kept signature-compatible with the legacy `HttpDispatcher.handleUi`. */
23+
export async function handleUiRequest(
24+
deps: DomainHandlerDeps,
25+
path: string,
26+
query: any,
27+
_context: HttpProtocolContext,
28+
): Promise<HttpDispatcherResult> {
29+
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
30+
31+
// GET /ui/view/:object (with optional type param)
32+
if (parts[0] === 'view' && parts[1]) {
33+
const objectName = parts[1];
34+
// Support both path param /view/obj/list AND query param /view/obj?type=list
35+
const type = parts[2] || query?.type || 'list';
36+
37+
const protocol = await deps.resolveService('protocol');
38+
39+
if (protocol && typeof protocol.getUiView === 'function') {
40+
try {
41+
const result = await protocol.getUiView({ object: objectName, type });
42+
return { handled: true, response: deps.success(result) };
43+
} catch (e: any) {
44+
return { handled: true, response: deps.error(e.message, 500) };
45+
}
46+
} else {
47+
return { handled: true, response: deps.error('Protocol service not available', 503) };
48+
}
49+
}
50+
51+
return { handled: false };
52+
}

0 commit comments

Comments
 (0)