Files
openmaic/OpenMAIC/packages/@openmaic/storage/test/kv-conformance-server.ts
2026-08-16 14:58:47 +08:00

460 lines
16 KiB
TypeScript

// Test-only HTTP adapter implementing the KV HTTP contract, so the shared
// contract suite can run against the real client over a real request / response
// boundary. It keeps its state in memory: the server-side Postgres backend is a
// separate part, and this file must not quietly become one.
//
// It is a conformance harness, not a reference server. Its credential handling
// exists to exercise the contract's authentication and authorization responses;
// it is not an authentication model. Deriving a principal from an authenticated
// session belongs to the reference server.
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { assertJsonValue } from '../src/runtime/json-value.js';
export interface KvConformanceServer {
baseUrl: string;
fetch: typeof globalThis.fetch;
close(): Promise<void>;
}
export interface KvConformanceServerOptions {
/** Bind a loopback TCP port. Tests can disable this in network-restricted sandboxes. */
listen?: boolean;
/** Request body ceiling; a larger body is rejected with `413`. */
maxBodyBytes?: number;
/** Return false to answer `401 UNAUTHENTICATED`. Defaults to allowing everything. */
authenticate?: (req: IncomingMessage) => boolean;
/** Return false to answer `403`. Defaults to allowing everything. */
authorize?: (req: IncomingMessage, area: 'kv') => boolean;
}
/** One principal's view. The namespace header stands in for a principal. */
interface Namespace {
kv: Map<string, string>;
}
class ConformanceHttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
function sendJson(
res: ServerResponse,
status: number,
body: unknown,
extraHeaders: Record<string, string> = {},
): void {
res.writeHead(status, { 'content-type': 'application/json', ...extraHeaders });
res.end(JSON.stringify(body));
}
/**
* The contract requires reads to be uncacheable, so the harness has to send the
* header itself — otherwise the suite would pass against an implementation that
* omits it, which is precisely the implementation the requirement exists to
* catch.
*/
const NO_STORE = { 'cache-control': 'no-store' };
function sendNoContent(res: ServerResponse): void {
res.writeHead(204);
res.end();
}
function errorResponse(error: unknown): { status: number; body: unknown } {
if (error instanceof ConformanceHttpError) {
return { status: error.status, body: { error: { code: error.code, message: error.message } } };
}
// Never echo an internal failure's message: the contract's INTERNAL_ERROR row
// promises the handler does not expose internal details.
return {
status: 500,
body: {
error: { code: 'INTERNAL_ERROR', message: '@openmaic/storage: internal server error' },
},
};
}
async function readBytes(req: IncomingMessage, maxBodyBytes: number): Promise<Buffer> {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer);
total += buffer.length;
if (total > maxBodyBytes) {
throw new ConformanceHttpError(
413,
'PAYLOAD_TOO_LARGE',
`@openmaic/storage: request body exceeds ${maxBodyBytes} bytes`,
);
}
chunks.push(buffer);
}
return Buffer.concat(chunks);
}
async function readJson<T>(req: IncomingMessage, maxBodyBytes: number): Promise<T> {
const raw = await readBytes(req, maxBodyBytes);
if (raw.length === 0) {
throw new ConformanceHttpError(400, 'VALIDATION_FAILED', 'request body must be a JSON object');
}
let body: unknown;
try {
body = JSON.parse(raw.toString('utf8')) as unknown;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ConformanceHttpError(400, 'VALIDATION_FAILED', message);
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
throw new ConformanceHttpError(400, 'VALIDATION_FAILED', 'request body must be a JSON object');
}
return body as T;
}
// The server imposes **no** key-domain rules: a key arrives percent-decoded and
// is stored as a plain Map key, so any string — empty, containing NUL, `/`, `\`,
// a `..`, of any length — is a legitimate opaque key that traverses nothing. The
// only transport-level rejections are a malformed percent-escape (handled in
// pathParts) and, automatically, Node's own request-target size limit; both are
// properties of the HTTP transport, not of the key. Prefixes are opaque too.
function routeNotFound(res: ServerResponse): void {
sendJson(res, 404, { error: { code: 'ROUTE_NOT_FOUND', message: 'route not found' } });
}
function pathParts(req: IncomingMessage): { parts: string[]; url: URL } {
const target = req.url ?? '/';
const url = new URL(target, 'http://conformance.invalid');
// Split the RAW request target, not `url.pathname`. The WHATWG parser resolves
// dot segments before anything here can look, and it treats `%2e` as one — so
// `/kv/entries/%2e%2e` arrives already collapsed, and a validator reading the
// parsed path would be inspecting a request nobody sent. The rules exist to
// reject what was *received*, so the segments come from the wire.
const rawPath = target.split(/[?#]/, 1)[0] ?? '/';
const rawParts = rawPath.split('/');
if (rawParts[0] === '') rawParts.shift();
const parts: string[] = [];
for (const part of rawParts) {
let decoded: string;
try {
decoded = decodeURIComponent(part);
} catch {
// A malformed escape (`%`, `%zz`, a surrogate encoding) is a bad request,
// not a server fault; the native URIError would otherwise surface as the
// contract's INTERNAL_ERROR row.
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
'@openmaic/storage: request path is not valid percent-encoded UTF-8',
);
}
parts.push(decoded);
}
return { parts, url };
}
/**
* Every header spelling that would convey a scope. The contract has no scope
* header, so a request carrying any of these is trying to describe one and must
* fail loud rather than have its intent silently discarded. Enumerated because
* "just `x-scope`" left the others open — a client could reach for `scope`,
* `kv-scope`, or the `x-` prefixed variants just as naturally.
*/
const PROHIBITED_SCOPE_HEADERS = ['scope', 'x-scope', 'kv-scope', 'x-kv-scope'];
/**
* The contract has no scope anywhere on the wire, so a client that invents one
* has to fail loud. That covers every channel a scope could hide in: a path
* segment, the query string, any scope-spelling header, and (elsewhere) the body.
*/
function assertNoScopeChannel(req: IncomingMessage, url: URL, parts: string[]): void {
// A scope path segment — `/kv/device/keys`, `/kv/account/entries/k` — is an
// attempt to route by scope. The contract's segment after `kv` is `entries` or
// `keys`; a scope name there is rejected, not silently 404'd. (A *key* named
// `device` is fine: that sits at parts[2], after `entries`.)
if (parts[1] === 'device' || parts[1] === 'account') {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
'@openmaic/storage: kv requests must not carry a scope path segment — this contract is ' +
'account-scoped and the principal is derived server-side',
);
}
if (url.searchParams.has('scope')) {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
'@openmaic/storage: kv requests must not carry a scope query parameter — this contract is ' +
'account-scoped and the principal is derived server-side',
);
}
for (const header of PROHIBITED_SCOPE_HEADERS) {
if (req.headers[header] !== undefined) {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
`@openmaic/storage: kv requests must not carry a scope header (${header})`,
);
}
}
}
interface RouteContext {
state: Namespace;
parts: string[];
url: URL;
maxBodyBytes: number;
}
/**
* A body is a scope channel too, and the two bodyless methods (GET, DELETE) have
* no legitimate use for one — so any body on them is refused outright rather than
* parsed for the scope it might be smuggling. A GET route that simply ignored
* the body would let `GET /kv/keys` with `{"scope":"device"}` succeed, which is
* exactly the "every channel is closed" guarantee failing silently. PUT reads
* its body and rejects a scope *field* there instead, since it legitimately
* carries `value`.
*/
async function assertNoRequestBody(req: IncomingMessage, maxBodyBytes: number): Promise<void> {
const body = await readBytes(req, maxBodyBytes);
if (body.length > 0) {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
`@openmaic/storage: ${req.method ?? 'GET'} must not carry a request body — this contract is ` +
'account-scoped and the principal is derived server-side',
);
}
}
async function routeKv(
req: IncomingMessage,
res: ServerResponse,
context: RouteContext,
): Promise<boolean> {
const { state, parts, url, maxBodyBytes } = context;
const method = req.method ?? 'GET';
assertNoScopeChannel(req, url, parts);
// Close the body channel on every bodyless method up front, so no GET route
// can forget to look. PUT is the one method that reads a body, and it checks
// the body for a scope field itself.
if (method === 'GET' || method === 'DELETE') {
await assertNoRequestBody(req, maxBodyBytes);
}
if (method === 'GET' && parts.length === 2 && parts[1] === 'keys') {
const prefix = url.searchParams.get('prefix') ?? '';
// A literal, byte-for-byte prefix comparison over opaque keys. Spelled out
// because the obvious SQL translation is `LIKE prefix || '%'`, where an
// unescaped `%`, `_`, or `\` in a caller-supplied prefix silently becomes a
// wildcard or an escape.
sendJson(
res,
200,
[...state.kv.keys()].filter((key) => key.startsWith(prefix)),
NO_STORE,
);
return true;
}
if (parts.length === 3 && parts[1] === 'entries') {
// The decoded key is opaque and stored as a plain Map key — no validation.
const key = parts[2]!;
if (method === 'GET') {
const raw = state.kv.get(key);
if (raw === undefined) {
throw new ConformanceHttpError(
404,
'KEY_NOT_FOUND',
`@openmaic/storage: no kv entry ${JSON.stringify(key)}`,
);
}
sendJson(res, 200, { value: JSON.parse(raw) as unknown }, NO_STORE);
return true;
}
if (method === 'PUT') {
const body = await readJson<{ value?: unknown }>(req, maxBodyBytes);
if (!('value' in body)) {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
'@openmaic/storage: kv write body must carry "value"',
);
}
if ('scope' in body) {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
'@openmaic/storage: kv write body must not carry a scope — this contract is ' +
'account-scoped and the principal is derived server-side',
);
}
try {
assertJsonValue(body.value, `kv value for key ${JSON.stringify(key)}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ConformanceHttpError(400, 'VALIDATION_FAILED', message);
}
state.kv.set(key, JSON.stringify(body.value));
sendNoContent(res);
return true;
}
if (method === 'DELETE') {
// The body was already refused up front (bodyless method); nothing to do
// here but delete.
state.kv.delete(key);
sendNoContent(res);
return true;
}
}
return false;
}
/**
* Start a test-only HTTP adapter. Each `x-storage-namespace` header selects a
* fresh in-memory namespace, so factories used by the shared contract suite stay
* isolated.
*/
export async function startKvConformanceServer(
options: KvConformanceServerOptions = {},
): Promise<KvConformanceServer> {
const namespaces = new Map<string, Namespace>();
const maxBodyBytes = options.maxBodyBytes ?? 32 * 1024 * 1024;
const authenticate = options.authenticate ?? (() => true);
const authorize = options.authorize ?? (() => true);
const namespaceFor = (name: string): Namespace => {
let state = namespaces.get(name);
if (!state) {
state = { kv: new Map() };
namespaces.set(name, state);
}
return state;
};
const namespaceNameFor = (req: IncomingMessage): string => {
const header = req.headers['x-storage-namespace'];
return typeof header === 'string' && header !== '' ? header : 'default';
};
let baseUrl = 'http://kv-conformance.invalid';
const route = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const { parts, url } = pathParts(req);
if (parts[0] !== 'kv') {
routeNotFound(res);
return;
}
if (!authenticate(req)) {
throw new ConformanceHttpError(
401,
'UNAUTHENTICATED',
'@openmaic/storage: missing or invalid credential',
);
}
if (!authorize(req, 'kv')) {
throw new ConformanceHttpError(
403,
'FORBIDDEN_KV',
'@openmaic/storage: principal may not perform this operation',
);
}
const context: RouteContext = {
state: namespaceFor(namespaceNameFor(req)),
parts,
url,
maxBodyBytes,
};
if (!(await routeKv(req, res, context))) routeNotFound(res);
};
const server = createServer((req, res) => {
void route(req, res).catch((error: unknown) => {
const mapped = errorResponse(error);
sendJson(res, mapped.status, mapped.body);
});
});
if (options.listen !== false) {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (address === null || typeof address === 'string') {
throw new Error('KV conformance server did not bind a TCP port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
}
const injectedFetch: typeof globalThis.fetch = async (input, init) => {
const request = new Request(input, init);
const url = new URL(request.url);
const requestBody = Buffer.from(await request.arrayBuffer());
// Recover the raw request target when the caller passed a string. `Request`
// normalizes dot segments — including `%2e` — exactly as `URL` does, so
// building the target from the parsed URL would hide from this server the
// very inputs a real client can put on the wire.
const rawTarget =
typeof input === 'string'
? input.slice(url.origin.length) || '/'
: `${url.pathname}${url.search}`;
const fakeRequest = {
method: request.method,
url: rawTarget,
headers: Object.fromEntries(request.headers.entries()),
async *[Symbol.asyncIterator]() {
if (requestBody.length > 0) yield requestBody;
},
} as unknown as IncomingMessage;
let status = 200;
let responseHeaders: Record<string, string> = {};
let responseBody: string | Uint8Array<ArrayBuffer> | undefined;
const fakeResponse = {
writeHead(nextStatus: number, headers?: Record<string, string>) {
status = nextStatus;
responseHeaders = headers ?? {};
return this;
},
end(chunk?: string | Buffer) {
// Copy into a plain Uint8Array: a Node `Buffer` is one structurally but
// does not satisfy `BodyInit` under the DOM lib's narrowing.
responseBody =
typeof chunk === 'string' || chunk === undefined ? chunk : new Uint8Array(chunk);
return this;
},
} as unknown as ServerResponse;
try {
await route(fakeRequest, fakeResponse);
} catch (error) {
const mapped = errorResponse(error);
status = mapped.status;
responseHeaders = { 'content-type': 'application/json' };
responseBody = JSON.stringify(mapped.body);
}
return new Response(status === 204 ? null : responseBody, {
status,
headers: responseHeaders,
});
};
return {
baseUrl,
fetch: injectedFetch,
close: () =>
server.listening
? new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
})
: Promise.resolve(),
};
}