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
16 changes: 16 additions & 0 deletions .changeset/runtime-domain-extraction-batch3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/runtime": minor
---

feat(runtime): extract /keys, /storage and /ui dispatcher domain bodies — ADR-0076 D11 step ③, PR-3 (#2462)

Continues the per-domain decomposition: three more handler bodies move out
of `HttpDispatcher` into `domains/keys.ts` (incl. the zero-tolerance
API-key-mint security contract), `domains/storage.ts` and `domains/ui.ts`,
running on the explicit `DomainHandlerDeps` contract (extended with
`getObjectQL` for the data-plane domains). The `/keys` legacy branch's
`'/keys?'` query-string form is reproduced with a second registry entry;
storage drops its strictly-redundant `kernel.services` index-access fallback
(dead under Map-shaped services, duplicate under object-shaped ones). Thin
`handleXxx` delegates remain for direct callers. Zero behavior change —
locked by the 41-assertion http-conformance suite and 6 new seam tests.
65 changes: 65 additions & 0 deletions packages/runtime/src/domain-handler-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,68 @@ describe('HttpDispatcher extracted domains (PR-2)', () => {
expect(result.response?.status ?? 404).not.toBe(200);
});
});

// ---------------------------------------------------------------------------
// PR-3 — keys / storage / ui extraction
// ---------------------------------------------------------------------------

describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => {
it('POST /keys rejects anonymous callers with 401 (identity gate inside the extracted body)', async () => {
const result = await makeDispatcher().dispatch('POST', '/keys', { name: 'k' }, {}, {} as any);
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(401);
});

it('GET /keys answers 405 (mint is POST-only), and /keysfoo is NOT claimed (segment semantics)', async () => {
const dispatcher = makeDispatcher();
const wrongMethod = await dispatcher.dispatch('GET', '/keys', undefined, {}, {} as any);
expect(wrongMethod.response?.status).toBe(405);
const lexical = await dispatcher.dispatch('POST', '/keysfoo', { name: 'k' }, {}, {} as any);
expect(lexical.response?.status ?? 404).not.toBe(405);
});

it('POST /keys mints a key pinned to the caller (thin delegate carries the extracted body)', async () => {
const insert = vi.fn().mockResolvedValue({ id: 'key-row-1' });
const objectql = {
insert,
find: vi.fn().mockResolvedValue([]),
getObjects: vi.fn().mockReturnValue({}),
registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) },
};
const context: any = { executionContext: { userId: 'caller-1' } };
// Direct delegate call — dispatch() would re-resolve identity off the
// auth-less mock kernel and overwrite the seeded executionContext.
const result = await makeDispatcher({ objectql }).handleKeys('POST', { name: 'CI Key', user_id: 'attacker' }, context);
expect(result.response?.status).toBe(201);
const row = insert.mock.calls[0][1];
expect(insert.mock.calls[0][0]).toBe('sys_api_key');
// user_id pinned to caller; body's user_id ignored; only the hash stored.
expect(row.user_id).toBe('caller-1');
expect(row.key).not.toBe(result.response?.body?.data?.key);
expect(result.response?.body?.data?.key).toBeTruthy();
});

it('/storage responds 501 when file-storage is not configured (extracted body keeps in-handler semantics)', async () => {
const result = await makeDispatcher().dispatch('POST', '/storage/upload', { blob: 1 }, {}, {} as any);
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(501);
});

it('/storage/upload uploads through the file-storage service', async () => {
const upload = vi.fn().mockResolvedValue({ id: 'f1' });
const result = await makeDispatcher({ 'file-storage': { upload, download: vi.fn() } })
.dispatch('POST', '/storage/upload', { some: 'file' }, {}, {} as any);
expect(result.response?.status).toBe(200);
expect(upload).toHaveBeenCalledTimes(1);
});

it('/ui/view/:object serves the protocol getUiView result; 503 without a protocol service', async () => {
const getUiView = vi.fn().mockResolvedValue({ view: 'list-def' });
const ok = await makeDispatcher({ protocol: { getUiView } }).dispatch('GET', '/ui/view/account/list', undefined, {}, {} as any);
expect(ok.response?.status).toBe(200);
expect(getUiView).toHaveBeenCalledWith({ object: 'account', type: 'list' });

const missing = await makeDispatcher().dispatch('GET', '/ui/view/account', undefined, {}, {} as any);
expect(missing.response?.status).toBe(503);
});
});
7 changes: 7 additions & 0 deletions packages/runtime/src/domain-handler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export interface DomainHandlerDeps {
resolveService(name: string, environmentId?: string): any;
/** Unscoped service lookup on the current kernel (may return a Promise). */
getService(name: string): any;
/**
* Environment-scoped ObjectQL lookup with a registry-shape check
* (resolves the `objectql` service and returns it only when it exposes
* `.registry`; null otherwise). The data-plane domains (/keys today,
* /data /meta when they migrate) depend on this.
*/
getObjectQL(environmentId?: string): Promise<any>;
/** Standard success envelope. */
success(data: any, meta?: any): { status: number; body: any };
/** Standard error envelope. */
Expand Down
125 changes: 125 additions & 0 deletions packages/runtime/src/domains/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `/keys` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-3).
*
* Generates a `sys_api_key` and returns the raw secret EXACTLY ONCE
* (`POST /keys`). This is the only mint path — the raw key is never stored
* (only its sha256 hash) and never re-displayable.
*
* Security (zero-tolerance):
* - Requires an authenticated principal; `user_id` is PINNED to that
* caller and is NEVER read from the request body (no impersonation).
* - Body is whitelisted to `name` (+ optional `expires_at`); any
* `key` / `id` / `user_id` / `revoked` in the body is ignored, so a
* caller cannot forge a known-secret or escalate.
* - `scopes` are intentionally NOT accepted from the body in v1: the
* verify path ADDS scopes to the principal's permissions, so honouring
* arbitrary body scopes would be an escalation vector. A generated key
* therefore acts exactly AS the caller (via `user_id` resolution).
* Narrowing/scoped keys need subset-enforcement — deferred.
* - The raw key and its hash never enter logs or error messages.
* - The row is written with an elevated `{ isSystem: true }` context
* because `sys_api_key` is protection-locked; safe because the row's
* contents are fully server-controlled (user_id pinned to caller).
*/

import { generateApiKey } from '../security/api-key.js';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';

/**
* The legacy branch matched `=== '/keys' || startsWith('/keys/') ||
* startsWith('/keys?')` — a segment match PLUS the query-string form some
* adapters pass through in `path`. Two entries reproduce that exactly.
*/
export function createKeysDomains(deps: DomainHandlerDeps): DomainRoute[] {
const handler: DomainRoute['handler'] = (req, context) =>
handleKeysRequest(deps, req.method, req.body, context);
return [
{ prefix: '/keys', match: 'segment', handler },
{ prefix: '/keys?', handler },
];
}

/** Body kept signature-compatible with the legacy `HttpDispatcher.handleKeys`. */
export async function handleKeysRequest(
deps: DomainHandlerDeps,
method: string,
body: any,
context: HttpProtocolContext,
): Promise<HttpDispatcherResult> {
if (method !== 'POST') {
return { handled: true, response: deps.error('Method not allowed', 405) };
}

const ec = context.executionContext;
if (!ec || !ec.userId) {
return { handled: true, response: deps.error('Unauthorized: sign in to generate an API key', 401) };
}

// ── Whitelist the body. Only `name` and optional `expires_at`. ──
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
const name = rawName || 'API Key';

let expiresAt: string | undefined;
if (body?.expires_at != null && body.expires_at !== '') {
const ms = typeof body.expires_at === 'number'
? (body.expires_at < 1e12 ? body.expires_at * 1000 : body.expires_at)
: Date.parse(String(body.expires_at));
if (Number.isNaN(ms)) {
return { handled: true, response: deps.error('Invalid expires_at: must be a parseable date', 400) };
}
if (ms <= Date.now()) {
return { handled: true, response: deps.error('Invalid expires_at: must be in the future', 400) };
}
expiresAt = new Date(ms).toISOString();
}

const ql = (await deps.getObjectQL(context.environmentId))
?? (await deps.resolveService('objectql', context.environmentId));
if (!ql || typeof ql.insert !== 'function') {
return { handled: true, response: deps.error('Data service not available', 503) };
}

// Generate AFTER validation so we never mint on a rejected request.
const generated = generateApiKey();

// Server-controlled row. user_id is pinned to the caller; only the hash
// is persisted. NOTHING from the body can set key/id/user_id/revoked.
const row: Record<string, unknown> = {
name,
key: generated.hash,
prefix: generated.prefix,
user_id: ec.userId,
revoked: false,
};
if (expiresAt) row.expires_at = expiresAt;

let inserted: any;
try {
inserted = await ql.insert('sys_api_key', row, { context: { isSystem: true } });
} catch {
// Never surface the underlying error (could echo row contents).
return { handled: true, response: deps.error('Failed to create API key', 500) };
}
const id = inserted?.id ?? (Array.isArray(inserted) ? inserted[0]?.id : undefined);

// Raw key returned ONCE. Do not log it.
return {
handled: true,
response: {
status: 201,
body: {
success: true,
data: {
id,
name,
prefix: generated.prefix,
key: generated.raw,
...(expiresAt ? { expires_at: expiresAt } : {}),
},
},
},
};
}
85 changes: 85 additions & 0 deletions packages/runtime/src/domains/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `/storage` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-3).
* Upload / download bridge to the `file-storage` service. Download results
* may be a redirect or a stream — those come back as `result:{type:...}` for
* the HTTP adapter to realize (the dispatcher envelope can't carry them).
*
* Routes (path is the sub-path after `/storage`):
* POST /upload → upload (body is the file/stream)
* GET /file/:id → download (redirect | stream | metadata)
*/

import { CoreServiceName } from '@objectstack/spec/system';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';

export function createStorageDomain(deps: DomainHandlerDeps): DomainRoute {
return {
prefix: '/storage',
handler: (req, context) =>
handleStorageRequest(deps, req.path.substring(8), req.method, req.body, context),
};
}

/** Body kept signature-compatible with the legacy `HttpDispatcher.handleStorage`. */
export async function handleStorageRequest(
deps: DomainHandlerDeps,
path: string,
method: string,
file: any,
context: HttpProtocolContext,
): Promise<HttpDispatcherResult> {
// The legacy body had `getService(...) || this.kernel.services?.['file-storage']`.
// The second leg was strictly redundant: resolveService's fallback chain
// already ends at the services map (and when `services` is a Map, the
// legacy index access returned undefined anyway), so it is dropped here.
const storageService = await deps.getService(CoreServiceName.enum['file-storage']);
if (!storageService) {
return { handled: true, response: deps.error('File storage not configured', 501) };
}

const m = method.toUpperCase();
const parts = path.replace(/^\/+/, '').split('/');

// POST /storage/upload
if (parts[0] === 'upload' && m === 'POST') {
if (!file) {
return { handled: true, response: deps.error('No file provided', 400) };
}
const result = await storageService.upload(file, { request: context.request });
return { handled: true, response: deps.success(result) };
}

// GET /storage/file/:id
if (parts[0] === 'file' && parts[1] && m === 'GET') {
const id = parts[1];
const result = await storageService.download(id, { request: context.request });

// Result can be URL (redirect), Stream/Blob, or metadata
if (result.url && result.redirect) {
// Must be handled by adapter to do actual redirect
return { handled: true, result: { type: 'redirect', url: result.url } };
}

if (result.stream) {
// Must be handled by adapter to pipe stream
return {
handled: true,
result: {
type: 'stream',
stream: result.stream,
headers: {
'Content-Type': result.mimeType || 'application/octet-stream',
'Content-Length': result.size
}
}
};
}

return { handled: true, response: deps.success(result) };
}

return { handled: false };
}
52 changes: 52 additions & 0 deletions packages/runtime/src/domains/ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `/ui` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-3).
* Serves rendered view metadata from the `protocol` service.
*
* Routes (path is the sub-path after `/ui`):
* GET /view/:object[/:type] → getUiView (type also accepted as ?type=)
*/

import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';

export function createUiDomain(deps: DomainHandlerDeps): DomainRoute {
return {
prefix: '/ui',
handler: (req, context) =>
handleUiRequest(deps, req.path.substring(3), req.query, context),
};
}

/** Body kept signature-compatible with the legacy `HttpDispatcher.handleUi`. */
export async function handleUiRequest(
deps: DomainHandlerDeps,
path: string,
query: any,
_context: HttpProtocolContext,
): Promise<HttpDispatcherResult> {
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);

// GET /ui/view/:object (with optional type param)
if (parts[0] === 'view' && parts[1]) {
const objectName = parts[1];
// Support both path param /view/obj/list AND query param /view/obj?type=list
const type = parts[2] || query?.type || 'list';

const protocol = await deps.resolveService('protocol');

if (protocol && typeof protocol.getUiView === 'function') {
try {
const result = await protocol.getUiView({ object: objectName, type });
return { handled: true, response: deps.success(result) };
} catch (e: any) {
return { handled: true, response: deps.error(e.message, 500) };
}
} else {
return { handled: true, response: deps.error('Protocol service not available', 503) };
}
}

return { handled: false };
}
Loading