mindspace: close authority boundaries

This commit is contained in:
john
2026-07-27 15:34:35 +08:00
parent dfab78c75a
commit e94052ff24
78 changed files with 11962 additions and 2162 deletions
+191 -3
View File
@@ -3,6 +3,16 @@ import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MCP_TOOL_METHODS = Object.freeze({
create_dir: 'createDirectory',
edit_file: 'editFile',
generate_long_image: 'generateLongImage',
list_dir: 'listDirectory',
publish_page: 'publishPage',
read_file: 'readFile',
write_binary_file: 'writeBinaryFile',
write_file: 'writeFile',
});
async function loadContract(env = process.env) {
const memindRoot = path.resolve(__dirname, env.MINDSPACE_MEMIND_ROOT ?? '../memind-source');
@@ -11,19 +21,48 @@ async function loadContract(env = process.env) {
);
}
async function loadMcpScopedTokenVerifier(env = process.env) {
const memindRoot = path.resolve(__dirname, env.MINDSPACE_MEMIND_ROOT ?? '../memind-source');
return import(
pathToFileURL(path.join(memindRoot, 'mindspace-mcp-scoped-token.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) {
async function readJsonBody(
req,
{ maxBytes = 32 * 1024 * 1024 } = {},
) {
const chunks = [];
let receivedBytes = 0;
for await (const chunk of req) {
chunks.push(chunk);
const buffer = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk);
receivedBytes += buffer.length;
if (receivedBytes > maxBytes) {
throw Object.assign(
new Error('MindSpace RPC request body is too large'),
{ code: 'request_body_too_large' },
);
}
chunks.push(buffer);
}
const text = Buffer.concat(chunks).toString('utf8').trim();
return text ? JSON.parse(text) : {};
if (!text) return {};
try {
return JSON.parse(text);
} catch {
throw Object.assign(
new Error('MindSpace RPC request body must be valid JSON'),
{ code: 'invalid_json_body' },
);
}
}
function reviveRpcValue(value) {
@@ -67,17 +106,39 @@ function resolveRpcErrorStatus(error) {
case 'publication_owner_not_found':
case 'page_not_found':
case 'category_not_found':
case 'static_page_not_found':
return 404;
case 'publication_login_required':
return 401;
case 'publication_password_required':
case 'mcp_tool_forbidden':
case 'mcp_scope_mismatch':
return 403;
case 'invalid_mcp_token':
case 'expired_mcp_token':
return 401;
case 'invalid_input':
case 'invalid_page_input':
case 'invalid_page_path':
case 'empty_page_content':
case 'invalid_publish_input':
case 'invalid_state_transition':
case 'slug_conflict':
case 'security_ack_required':
case 'invalid_workspace_tool_input':
case 'invalid_workspace_ref':
case 'invalid_workspace_path':
case 'invalid_workspace_package_scope':
case 'invalid_workspace_user_scope':
case 'invalid_workspace_page_path':
case 'invalid_workspace_binary':
case 'invalid_json_body':
return 400;
case 'workspace_entry_not_found':
return 404;
case 'workspace_file_too_large':
case 'request_body_too_large':
return 413;
default:
return 500;
}
@@ -99,6 +160,35 @@ export async function createMindSpaceRpcRequestHandler({
.trim()
.replace(/\/+$/, '') || '/mindspace/v1/adapter';
const authToken = String(env.MINDSPACE_REMOTE_AUTH_TOKEN ?? '').trim();
const mcpOperationBasePath = String(
serviceMeta.mcpOperationBasePath ??
env.MINDSPACE_MCP_OPERATION_BASE_PATH ??
'/mindspace/v1/mcp',
)
.trim()
.replace(/\/+$/, '') ||
'/mindspace/v1/mcp';
const mcpTokenSecret = String(
env.MINDSPACE_MCP_TOKEN_SECRET ?? '',
).trim();
if (
mcpTokenSecret &&
mcpTokenSecret.length < 16
) {
throw new Error(
'MINDSPACE_MCP_TOKEN_SECRET must contain at least 16 characters',
);
}
const mcpTokenVerifier = mcpTokenSecret
? await loadMcpScopedTokenVerifier(env)
: null;
const mcpMaxBodyBytes = Math.max(
1024,
Number(
env.MINDSPACE_MCP_MAX_BODY_BYTES ??
12 * 1024 * 1024,
) || 12 * 1024 * 1024,
);
return async function handleMindSpaceRpc(req, res) {
try {
@@ -110,6 +200,9 @@ export async function createMindSpaceRpcRequestHandler({
adapterKind: adapter.kind,
implementationStatus: adapter.implementationStatus,
operationBasePath,
mcpOperationBasePath,
mcpScopedToolsEnabled:
Boolean(mcpTokenSecret),
backgroundJobsManagedLocally: adapter.kind === 'local',
});
}
@@ -119,6 +212,101 @@ export async function createMindSpaceRpcRequestHandler({
bindingKeys: MINDSPACE_SERVER_ADAPTER_BINDING_KEYS,
});
}
if (
req.method === 'POST' &&
url.pathname.startsWith(
`${mcpOperationBasePath}/`,
)
) {
if (!mcpTokenSecret) {
return json(res, 503, {
message:
'MindSpace MCP scoped tokens are not configured',
});
}
const segments = url.pathname
.slice(
mcpOperationBasePath.length + 1,
)
.split('/')
.filter(Boolean);
if (segments.length !== 1) {
return json(res, 400, {
message:
'Expected /mindspace/v1/mcp/:tool',
});
}
const tool = decodeURIComponent(
segments[0],
);
const method = MCP_TOOL_METHODS[tool];
if (
!method ||
typeof adapter?.workspaceToolService?.[
method
] !== 'function'
) {
return json(res, 404, {
message: `Unknown MCP tool: ${tool}`,
});
}
const claims =
mcpTokenVerifier.verifyMindSpaceMcpScopedToken({
token: parseBearerToken(
req.headers,
),
secret: mcpTokenSecret,
tool,
});
const body = await readJsonBody(req, {
maxBytes: mcpMaxBodyBytes,
});
const suppliedInput =
body?.arguments &&
typeof body.arguments === 'object'
? reviveRpcValue(body.arguments)
: {};
for (const [
field,
expected,
] of Object.entries({
userId: claims.userId,
sessionId: claims.sessionId,
packageId: claims.packageId,
workspaceRef:
claims.workspaceRef,
})) {
const supplied = String(
suppliedInput?.[field] ?? '',
).trim();
if (
supplied &&
supplied !== expected
) {
throw Object.assign(
new Error(
`${field} does not match the scoped MCP token`,
),
{ code: 'mcp_scope_mismatch' },
);
}
}
const result =
await adapter.workspaceToolService[
method
]({
...suppliedInput,
userId: claims.userId,
sessionId: claims.sessionId,
packageId: claims.packageId,
workspaceRef:
claims.workspaceRef,
});
return json(res, 200, {
ok: true,
result,
});
}
if (req.method !== 'POST' || !url.pathname.startsWith(`${operationBasePath}/`)) {
return json(res, 404, { message: 'Not found' });
}