Files
memind/mindspace-service/mindspace-rpc-server.mjs
T
john 53eb0014ba fix(mindspace): map publication RPC errors to HTTP status codes
Return 404/401/403 from MindSpace RPC for known publication errors and
preserve error codes through the Portal remote adapter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:44:58 +08:00

192 lines
5.7 KiB
JavaScript

import http from 'node:http';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function loadContract(env = process.env) {
const memindRoot = path.resolve(__dirname, env.MINDSPACE_MEMIND_ROOT ?? '../memind-source');
return import(
pathToFileURL(path.join(memindRoot, 'mindspace-server-adapter-contract.mjs')).href
);
}
function parseBearerToken(headers) {
const auth = String(headers.authorization ?? '');
if (!auth.startsWith('Bearer ')) return '';
return auth.slice('Bearer '.length).trim();
}
async function readJsonBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString('utf8').trim();
return text ? JSON.parse(text) : {};
}
function reviveRpcValue(value) {
if (
value &&
typeof value === 'object' &&
value.type === 'Buffer' &&
Array.isArray(value.data)
) {
return Buffer.from(value.data);
}
if (Array.isArray(value)) return value.map((item) => reviveRpcValue(item));
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, reviveRpcValue(item)]),
);
}
return value;
}
function json(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function serializeRpcError(error) {
return {
message: error instanceof Error ? error.message : String(error),
code: error?.code ?? 'internal_error',
...(error?.details !== undefined ? { details: error.details } : {}),
};
}
function resolveRpcErrorStatus(error) {
switch (error?.code) {
case 'publication_not_found':
case 'publication_owner_not_found':
case 'page_not_found':
case 'category_not_found':
return 404;
case 'publication_login_required':
return 401;
case 'publication_password_required':
return 403;
case 'invalid_input':
case 'invalid_publish_input':
case 'invalid_state_transition':
case 'slug_conflict':
case 'security_ack_required':
return 400;
default:
return 500;
}
}
export async function createMindSpaceRpcRequestHandler({
adapter,
env = process.env,
logger = console,
serviceMeta = {},
} = {}) {
const {
MINDSPACE_SERVER_ADAPTER_BINDINGS,
MINDSPACE_SERVER_ADAPTER_BINDING_KEYS,
} = await loadContract(env);
const operationBasePath = String(
serviceMeta.operationBasePath ?? env.MINDSPACE_REMOTE_OPERATION_BASE_PATH ?? '/mindspace/v1/adapter',
)
.trim()
.replace(/\/+$/, '') || '/mindspace/v1/adapter';
const authToken = String(env.MINDSPACE_REMOTE_AUTH_TOKEN ?? '').trim();
return async function handleMindSpaceRpc(req, res) {
try {
const url = new URL(req.url, 'http://127.0.0.1');
if (req.method === 'GET' && url.pathname === '/health') {
return json(res, 200, {
ok: true,
service: 'mindspace-service',
adapterKind: adapter.kind,
implementationStatus: adapter.implementationStatus,
operationBasePath,
backgroundJobsManagedLocally: adapter.kind === 'local',
});
}
if (req.method === 'GET' && url.pathname === '/mindspace/v1/contract') {
return json(res, 200, {
bindings: MINDSPACE_SERVER_ADAPTER_BINDINGS,
bindingKeys: MINDSPACE_SERVER_ADAPTER_BINDING_KEYS,
});
}
if (req.method !== 'POST' || !url.pathname.startsWith(`${operationBasePath}/`)) {
return json(res, 404, { message: 'Not found' });
}
if (authToken && parseBearerToken(req.headers) !== authToken) {
return json(res, 401, { message: 'Unauthorized' });
}
const segments = url.pathname.slice(operationBasePath.length + 1).split('/').filter(Boolean);
if (segments.length !== 2) {
return json(res, 400, { message: 'Expected /:binding/:method' });
}
const [bindingKey, method] = segments.map(decodeURIComponent);
const service = adapter?.[bindingKey];
const allowedMethods = MINDSPACE_SERVER_ADAPTER_BINDINGS[bindingKey];
if (!service || !allowedMethods) {
return json(res, 404, { message: `Unknown binding: ${bindingKey}` });
}
if (!allowedMethods.includes(method)) {
return json(res, 404, { message: `Unknown method: ${bindingKey}.${method}` });
}
const body = await readJsonBody(req);
const args = Array.isArray(body?.args) ? reviveRpcValue(body.args) : [];
const result = await service[method](...args);
return json(res, 200, result);
} catch (error) {
const statusCode = resolveRpcErrorStatus(error);
if (statusCode >= 500) {
logger.error?.('[MindSpace RPC Error]', error);
}
return json(res, statusCode, serializeRpcError(error));
}
};
}
export async function startMindSpaceRpcServer({
adapter,
env = process.env,
logger = console,
serviceMeta = {},
} = {}) {
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env,
logger,
serviceMeta,
});
const host = env.MINDSPACE_SERVICE_HOST ?? '127.0.0.1';
const port = Number(env.MINDSPACE_SERVICE_PORT ?? 8082);
const server = http.createServer((req, res) => {
void handler(req, res);
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, () => resolve());
});
return {
server,
host,
port,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
}),
};
}