mindspace: version runtime contracts and audit packages
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
MINDSPACE_SERVER_ADAPTER_BINDINGS,
|
||||
MINDSPACE_SERVER_ADAPTER_BINDING_KEYS,
|
||||
MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION,
|
||||
MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS,
|
||||
MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES,
|
||||
} from './mindspace-server-adapter-contract.mjs';
|
||||
|
||||
function trimTrailingSlash(value) {
|
||||
@@ -27,6 +30,15 @@ function buildRemoteOperationUrl(endpoint, bindingKey, method, operationBasePath
|
||||
return `${base}${normalizedBasePath}/${encodeURIComponent(bindingKey)}/${encodeURIComponent(method)}`;
|
||||
}
|
||||
|
||||
function buildRemoteContractUrl(endpoint, contractPath = '/mindspace/v1/contract') {
|
||||
const base = resolveRemoteAdapterEndpoint(endpoint);
|
||||
if (!base) {
|
||||
throw new Error('MindSpace remote server adapter requires MINDSPACE_REMOTE_BASE_URL');
|
||||
}
|
||||
const normalizedPath = normalizeOperationBasePath(contractPath);
|
||||
return `${base}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function throwRemoteOperationError(response, bindingKey, method, bodyText, payload) {
|
||||
const error = new Error(
|
||||
payload?.message ??
|
||||
@@ -88,6 +100,114 @@ async function invokeRemoteOperation({
|
||||
}
|
||||
}
|
||||
|
||||
function formatContractIssue(issue) {
|
||||
if (typeof issue === 'string') return issue;
|
||||
return JSON.stringify(issue);
|
||||
}
|
||||
|
||||
export function validateMindSpaceRemoteContract(
|
||||
contract,
|
||||
{
|
||||
minContractVersion = MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION,
|
||||
requiredBindings = MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS,
|
||||
requiredCapabilities = MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES,
|
||||
} = {},
|
||||
) {
|
||||
const issues = [];
|
||||
const version = Number(contract?.contractVersion ?? 0);
|
||||
if (!Number.isFinite(version) || version < minContractVersion) {
|
||||
issues.push(
|
||||
`contractVersion ${contract?.contractVersion ?? '(missing)'} is below required ${minContractVersion}`,
|
||||
);
|
||||
}
|
||||
const bindings = contract?.bindings ?? {};
|
||||
for (const [bindingKey, methods] of Object.entries(requiredBindings ?? {})) {
|
||||
const exposed = bindings[bindingKey];
|
||||
if (!Array.isArray(exposed)) {
|
||||
issues.push(`missing binding ${bindingKey}`);
|
||||
continue;
|
||||
}
|
||||
for (const method of methods) {
|
||||
if (!exposed.includes(method)) {
|
||||
issues.push(`missing method ${bindingKey}.${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const capabilities = new Set(
|
||||
Array.isArray(contract?.requiredCapabilities)
|
||||
? contract.requiredCapabilities
|
||||
: [],
|
||||
);
|
||||
for (const capability of requiredCapabilities ?? []) {
|
||||
if (!capabilities.has(capability)) {
|
||||
issues.push(`missing capability ${capability}`);
|
||||
}
|
||||
}
|
||||
if (issues.length > 0) {
|
||||
const error = new Error(
|
||||
`MindSpace remote contract is incompatible: ${issues
|
||||
.map(formatContractIssue)
|
||||
.join('; ')}`,
|
||||
);
|
||||
error.code = 'mindspace_contract_incompatible';
|
||||
error.details = {
|
||||
issues,
|
||||
contractVersion: contract?.contractVersion ?? null,
|
||||
buildId: contract?.buildId ?? null,
|
||||
gitSha: contract?.gitSha ?? null,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
return contract;
|
||||
}
|
||||
|
||||
async function fetchRemoteContract({
|
||||
endpoint,
|
||||
contractPath,
|
||||
fetchFn,
|
||||
authToken,
|
||||
timeoutMs,
|
||||
}) {
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timeoutId =
|
||||
controller && Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? setTimeout(() => controller.abort(new Error('MindSpace remote contract request timed out')), timeoutMs)
|
||||
: null;
|
||||
try {
|
||||
const response = await fetchFn(buildRemoteContractUrl(endpoint, contractPath), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||||
},
|
||||
signal: controller?.signal,
|
||||
});
|
||||
const bodyText = await response.text().catch(() => '');
|
||||
let payload = null;
|
||||
if (bodyText) {
|
||||
try {
|
||||
payload = JSON.parse(bodyText);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(
|
||||
`MindSpace remote contract check failed with ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
|
||||
);
|
||||
error.code = 'mindspace_contract_unavailable';
|
||||
throw error;
|
||||
}
|
||||
if (!payload) {
|
||||
const error = new Error('MindSpace remote contract response must be valid JSON');
|
||||
error.code = 'mindspace_contract_invalid_json';
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function createRemoteBindingProxy({
|
||||
endpoint,
|
||||
bindingKey,
|
||||
@@ -128,6 +248,10 @@ export function createMindSpaceRemoteServerAdapter({
|
||||
endpoint = '',
|
||||
authToken = '',
|
||||
operationBasePath = '/mindspace/v1/adapter',
|
||||
contractPath = '/mindspace/v1/contract',
|
||||
minContractVersion = MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION,
|
||||
requiredBindings = MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS,
|
||||
requiredCapabilities = MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES,
|
||||
timeoutMs = 15_000,
|
||||
fetchFn = globalThis.fetch?.bind(globalThis),
|
||||
} = {}) {
|
||||
@@ -157,15 +281,31 @@ export function createMindSpaceRemoteServerAdapter({
|
||||
implementationStatus: 'scaffold',
|
||||
endpoint: endpointUrl,
|
||||
operationBasePath: normalizedOperationBasePath,
|
||||
contractPath: normalizeOperationBasePath(contractPath),
|
||||
timeoutMs,
|
||||
storageRoot: null,
|
||||
publicBaseUrl: null,
|
||||
storageAdapter: null,
|
||||
...bindings,
|
||||
assertReady() {
|
||||
async assertReady() {
|
||||
if (!endpointUrl) {
|
||||
throw new Error('MindSpace remote server adapter requires MINDSPACE_REMOTE_BASE_URL');
|
||||
}
|
||||
const contract = await fetchRemoteContract({
|
||||
endpoint: endpointUrl,
|
||||
contractPath,
|
||||
fetchFn: invoke,
|
||||
authToken,
|
||||
timeoutMs,
|
||||
});
|
||||
validateMindSpaceRemoteContract(contract, {
|
||||
minContractVersion,
|
||||
requiredBindings,
|
||||
requiredCapabilities,
|
||||
});
|
||||
logger.log?.(
|
||||
`MindSpace remote contract ok (version=${contract.contractVersion}, build=${contract.buildId ?? 'unknown'}, git=${contract.gitSha ?? 'unknown'})`,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
startBackgroundJobs() {
|
||||
@@ -182,7 +322,10 @@ export function createMindSpaceRemoteServerAdapter({
|
||||
}
|
||||
|
||||
export const mindspaceRemoteServerAdapterInternals = {
|
||||
buildRemoteContractUrl,
|
||||
buildRemoteOperationUrl,
|
||||
fetchRemoteContract,
|
||||
invokeRemoteOperation,
|
||||
normalizeOperationBasePath,
|
||||
validateMindSpaceRemoteContract,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user