53eb0014ba
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>
192 lines
5.7 KiB
JavaScript
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();
|
|
});
|
|
}),
|
|
};
|
|
}
|