feat(ontology): add core service catalog

This commit is contained in:
Codex
2026-06-20 12:54:03 +03:00
parent 83cca4224b
commit 2d5fef3948
59 changed files with 7878 additions and 0 deletions
@@ -0,0 +1,199 @@
import { resolveAssistantAction } from '../assistant-action-resolver.mjs'
const HUB_ACTIONS = new Set([
'hub.user.read_admin_summary',
'hub.invite.list_pending',
'hub.access_request.list_pending',
'hub.user.block',
'hub.user.unblock',
'hub.membership.change_role',
'hub.membership.disable',
'hub.assistant_access.change_role',
])
const MEMBERSHIP_ROLES = new Set(['client_owner', 'client_admin', 'member'])
const CORE_ASSISTANT_ROLES = new Set(['blocked', 'member', 'admin'])
function requireValue(input, key) {
const value = input[key]
if (value === undefined || value === null || String(value).trim() === '') {
throw new Error(`Missing required adapter input: ${key}`)
}
return String(value)
}
function encode(value) {
return encodeURIComponent(String(value))
}
function makeIdempotencyKey(actionId, input) {
if (input.idempotencyKey) return String(input.idempotencyKey)
return [
actionId,
input.actorUserId || 'actor_unknown',
input.targetUserId || input.membershipId || 'target_unknown',
input.targetRole || input.targetAssistantRole || input.targetStatus || 'default',
].join(':')
}
function patchPlan(actionId, input, path, body, summary) {
return {
method: 'PATCH',
path,
body,
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': makeIdempotencyKey(actionId, input),
},
summary,
}
}
function buildPlanForAction(actionId, input) {
switch (actionId) {
case 'hub.user.read_admin_summary':
return {
method: 'GET',
path: '/api/admin/control-plane',
query: {
projection: 'users,memberships,services,grants,exceptions,invites,accessRequests',
clientId: input.clientId || null,
},
summary: 'Read admin-visible Launcher control-plane summary and filter in adapter output.',
}
case 'hub.invite.list_pending':
return {
method: 'GET',
path: '/api/admin/control-plane',
query: {
projection: 'invites',
status: 'pending',
clientId: input.clientId || null,
},
summary: 'Read Launcher invites and return pending invites visible in current admin scope.',
}
case 'hub.access_request.list_pending':
return {
method: 'GET',
path: '/api/admin/control-plane',
query: {
projection: 'accessRequests,engineWorkflowAccessRequests,users,clients',
status: 'pending',
clientId: input.clientId || null,
},
summary: 'Read Launcher access requests and return pending requests visible in current admin scope.',
}
case 'hub.user.block':
return patchPlan(
actionId,
input,
`/api/admin/users/${encode(requireValue(input, 'targetUserId'))}/profile`,
{ globalStatus: 'blocked' },
'Block Launcher user via guarded profile update route.',
)
case 'hub.user.unblock':
return patchPlan(
actionId,
input,
`/api/admin/users/${encode(requireValue(input, 'targetUserId'))}/profile`,
{ globalStatus: 'active' },
'Unblock Launcher user via guarded profile update route.',
)
case 'hub.membership.change_role': {
const targetRole = requireValue(input, 'targetRole')
if (!MEMBERSHIP_ROLES.has(targetRole)) {
throw new Error(`Invalid targetRole: ${targetRole}`)
}
return patchPlan(
actionId,
input,
`/api/admin/memberships/${encode(requireValue(input, 'membershipId'))}`,
{ role: targetRole },
'Change Launcher client membership role via guarded membership update route.',
)
}
case 'hub.membership.disable':
return patchPlan(
actionId,
input,
`/api/admin/memberships/${encode(requireValue(input, 'membershipId'))}`,
{ status: 'disabled' },
'Disable Launcher client membership via guarded membership update route.',
)
case 'hub.assistant_access.change_role': {
const targetAssistantRole = requireValue(input, 'targetAssistantRole')
if (!CORE_ASSISTANT_ROLES.has(targetAssistantRole)) {
throw new Error(`Invalid targetAssistantRole: ${targetAssistantRole}`)
}
return patchPlan(
actionId,
input,
`/api/admin/memberships/${encode(requireValue(input, 'membershipId'))}`,
{ coreAssistantRole: targetAssistantRole },
'Change NDC Core Assistant access role via guarded membership update route.',
)
}
default:
throw new Error(`Unsupported HUB action: ${actionId}`)
}
}
export async function buildHubLauncherAdminPlan(input = {}, options = {}) {
const decision = options.decision || await resolveAssistantAction(input, options)
const actionId = decision.action?.id
if (!actionId) {
return {
ok: false,
decision: 'unsupported_hub_action',
reason: 'Input did not resolve to a supported HUB Launcher admin action.',
actionDecision: decision,
}
}
if (
decision.decision !== 'allowed' &&
!(options.planPreview === true && decision.decision === 'needs_confirmation')
) {
return {
ok: false,
decision: decision.decision,
reason: decision.reason,
actionDecision: decision,
}
}
if (!HUB_ACTIONS.has(actionId)) {
return {
ok: false,
decision: 'unsupported_hub_action',
reason: 'Input did not resolve to a supported HUB Launcher admin action.',
actionDecision: decision,
}
}
const plan = buildPlanForAction(actionId, input)
const adapterReady = decision.action.adapterStatus === 'implemented'
return {
ok: true,
decision: 'planned',
adapterId: 'adapter.hub.launcher_admin_api',
adapterStatus: decision.action.adapterStatus,
executionReady: adapterReady,
dryRunOnly: !adapterReady,
gatewayActor: {
userId: input.actorUserId || null,
email: input.actorEmail || null,
subject: input.actorSubject || null,
},
safety: {
noDeleteRouteMapped: true,
requiresExternalExecutor: true,
sourceGuardedRoutesOnly: true,
confirmationAlreadyChecked: decision.execution.confirmed || options.planPreview === true,
},
request: plan,
actionDecision: decision,
}
}
@@ -0,0 +1,303 @@
import { createHash } from 'node:crypto'
import { resolveAssistantAction } from '../assistant-action-resolver.mjs'
const OPS_PRODUCT_API_ADAPTER_ID = 'adapter.ops.product_api'
const OPS_ACTIONS = new Set([
'ops.card.list_recent',
'ops.card.create',
'ops.card.add_comment',
])
const OPS_CARD_PRIORITIES = new Set(['none', 'low', 'medium', 'high', 'urgent'])
function cleanString(value) {
return String(value || '').trim()
}
function cleanText(value, max = 4000) {
const text = cleanString(value)
if (!text) return ''
return text.length > max ? text.slice(0, max).trim() : text
}
function cleanLimit(value) {
const number = Number(value)
if (!Number.isFinite(number)) return 5
return Math.min(20, Math.max(1, Math.trunc(number)))
}
function firstString(...values) {
for (const value of values) {
const text = cleanString(value)
if (text) return text
}
return ''
}
function normalizeProjectQuery(input = {}) {
return {
workspaceSlug: firstString(
input.workspaceSlug,
input.workspace_slug,
input.opsWorkspaceSlug,
input.ops_workspace_slug,
input.context?.workspaceSlug,
input.context?.opsWorkspaceSlug,
),
projectId: firstString(
input.projectId,
input.project_id,
input.opsProjectId,
input.ops_project_id,
input.context?.projectId,
input.context?.opsProjectId,
),
}
}
function cleanPriority(value) {
const text = cleanString(value).toLowerCase()
return OPS_CARD_PRIORITIES.has(text) ? text : ''
}
function cleanIssueId(value) {
return cleanString(value).replace(/^#/, '')
}
function idempotencyKeyFor(actionId, input, fallbackParts = {}) {
const explicit = firstString(
input.idempotencyKey,
input.idempotency_key,
input.context?.idempotencyKey,
input.context?.idempotency_key,
)
if (explicit) return explicit
const digest = createHash('sha256')
.update(JSON.stringify({
actionId,
actorUserId: input.actorUserId || null,
actorEmail: input.actorEmail || null,
...fallbackParts,
}))
.digest('hex')
.slice(0, 24)
return `ai-workspace:${actionId}:${digest}`
}
function buildPlanForAction(actionId, input = {}, options = {}) {
switch (actionId) {
case 'ops.card.list_recent': {
const normalized = normalizeProjectQuery(input)
const workspaceSlug = normalized.workspaceSlug || cleanString(options.defaultOpsWorkspaceSlug)
const projectId = normalized.projectId || cleanString(options.defaultOpsProjectId)
const limit = cleanLimit(input.limit)
if (!projectId) {
return {
ok: false,
decision: 'ops_project_context_missing',
reason: 'OPS project id is required for reading cards.',
}
}
return {
ok: true,
request: {
method: 'GET',
path: '/api/v1/tools/issues',
query: {
project_id: projectId,
...(workspaceSlug ? { workspace_slug: workspaceSlug } : {}),
...(cleanString(input.query || input.search) ? { query: cleanString(input.query || input.search) } : {}),
},
summary: 'Read recent OPS Product cards through local Ops Gateway REST tool route.',
},
opsContext: {
workspaceSlug,
projectId,
limit,
},
}
}
case 'ops.card.create': {
const normalized = normalizeProjectQuery(input)
const workspaceSlug = normalized.workspaceSlug || cleanString(options.defaultOpsWorkspaceSlug)
const projectId = normalized.projectId || cleanString(options.defaultOpsProjectId)
const title = cleanText(
firstString(input.title, input.name, input.cardTitle, input.taskTitle, input.summary),
260,
)
const description = cleanText(
firstString(input.description, input.body, input.details, input.text, input.message),
12000,
)
const priority = cleanPriority(input.priority)
if (!projectId) {
return {
ok: false,
decision: 'ops_project_context_missing',
reason: 'OPS project id is required for creating a card.',
}
}
if (!title) {
return {
ok: false,
decision: 'ops_card_title_missing',
reason: 'OPS card title is required.',
}
}
const body = {
project_id: projectId,
...(workspaceSlug ? { workspace_slug: workspaceSlug } : {}),
title,
...(description ? { description } : {}),
...(priority ? { priority } : {}),
}
return {
ok: true,
request: {
method: 'POST',
path: '/api/v1/tools/issues',
headers: {
'Idempotency-Key': idempotencyKeyFor(actionId, input, body),
},
body,
summary: 'Create OPS Product card through local Ops Gateway REST tool route.',
},
opsContext: {
workspaceSlug,
projectId,
},
}
}
case 'ops.card.add_comment': {
const normalized = normalizeProjectQuery(input)
const workspaceSlug = normalized.workspaceSlug || cleanString(options.defaultOpsWorkspaceSlug)
const projectId = normalized.projectId || cleanString(options.defaultOpsProjectId)
const issueId = cleanIssueId(firstString(
input.issueId,
input.issue_id,
input.cardId,
input.card_id,
input.targetCardId,
input.target_card_id,
input.context?.issueId,
input.context?.issue_id,
input.context?.cardId,
input.context?.card_id,
))
const bodyText = cleanText(
firstString(input.body, input.comment, input.text, input.message),
12000,
)
if (!projectId) {
return {
ok: false,
decision: 'ops_project_context_missing',
reason: 'OPS project id is required for commenting on a card.',
}
}
if (!issueId) {
return {
ok: false,
decision: 'ops_issue_id_missing',
reason: 'OPS issue id is required for commenting on a card.',
}
}
if (!bodyText) {
return {
ok: false,
decision: 'ops_comment_body_missing',
reason: 'OPS comment body is required.',
}
}
const body = {
project_id: projectId,
...(workspaceSlug ? { workspace_slug: workspaceSlug } : {}),
body: bodyText,
}
return {
ok: true,
request: {
method: 'POST',
path: `/api/v1/tools/issues/${encodeURIComponent(issueId)}/comments`,
headers: {
'Idempotency-Key': idempotencyKeyFor(actionId, input, { issueId, ...body }),
},
body,
summary: 'Append comment to OPS Product card through local Ops Gateway REST tool route.',
},
opsContext: {
workspaceSlug,
projectId,
issueId,
},
}
}
default:
return {
ok: false,
decision: 'unsupported_ops_action',
reason: `Unsupported OPS action: ${actionId}`,
}
}
}
export async function buildOpsProductPlan(input = {}, options = {}) {
const decision = options.decision || await resolveAssistantAction(input, options)
const actionId = decision.action?.id
if (!actionId || !OPS_ACTIONS.has(actionId)) {
return {
ok: false,
decision: 'unsupported_ops_action',
reason: 'Input did not resolve to a supported OPS Product action.',
actionDecision: decision,
}
}
if (decision.decision !== 'allowed') {
return {
ok: false,
decision: decision.decision,
reason: decision.reason,
actionDecision: decision,
}
}
const actionPlan = buildPlanForAction(actionId, input, options)
if (!actionPlan.ok) {
return {
...actionPlan,
actionDecision: decision,
}
}
return {
ok: true,
decision: 'planned',
adapterId: OPS_PRODUCT_API_ADAPTER_ID,
adapterStatus: decision.action.adapterStatus,
executionReady: decision.execution?.executionReady === true,
dryRunOnly: decision.action.adapterStatus !== 'implemented',
actionDecision: decision,
gatewayActor: {
userId: input.actorUserId || null,
email: input.actorEmail || null,
subject: input.actorSubject || null,
},
request: actionPlan.request,
opsContext: actionPlan.opsContext,
safety: {
noDeleteRouteMapped: true,
sourceGuardedRoutesOnly: true,
confirmationAlreadyChecked: true,
},
}
}
@@ -0,0 +1,268 @@
import fs from 'node:fs/promises'
import { executeAssistantAction } from './assistant-action-executor.mjs'
import { validateCatalog } from './validate.mjs'
function actionFromResult(result) {
return result?.plan?.actionDecision?.action || null
}
function actorFromResult(result) {
return result?.plan?.actionDecision?.actor || null
}
function requestFromResult(result) {
return result?.plan?.request || null
}
function executionStateFromResult(result) {
const action = actionFromResult(result)
const request = requestFromResult(result)
const confirmationEnvelope = result?.confirmationEnvelope || null
return {
adapterId: result?.plan?.adapterId || action?.adapterId || null,
adapterStatus: result?.plan?.adapterStatus || action?.adapterStatus || null,
executionReady: Boolean(result?.plan?.executionReady),
requiresUserConfirmation: Boolean(confirmationEnvelope),
requiresInternalToken: Boolean(result?.plan?.adapterId === 'adapter.hub.launcher_admin_api'),
method: request?.method || null,
path: request?.path || null,
idempotencyKey: request?.headers?.['Idempotency-Key'] || request?.headers?.['idempotency-key'] || null,
}
}
function previewFromDryRun(result) {
const action = actionFromResult(result)
const actor = actorFromResult(result)
const request = requestFromResult(result)
const confirmationEnvelope = result?.confirmationEnvelope || null
return {
ok: Boolean(result?.ok),
phase: 'preview',
decision: result?.ok ? 'preview_ready' : result?.decision || 'preview_blocked',
reason: result?.reason || null,
action,
actor,
preview: {
app: action?.app || null,
actionId: action?.id || null,
riskLevel: action?.riskLevel || null,
confirmationMode: action?.confirmationMode || null,
expectedEffect: confirmationEnvelope?.expectedEffect || action?.summary || request?.summary || null,
request: request ? {
method: request.method,
path: request.path,
query: request.query || null,
body: request.body ?? null,
} : null,
safeAlternatives: result?.plan?.actionDecision?.safeAlternatives || [],
},
confirmation: confirmationEnvelope ? {
token: confirmationEnvelope.token,
version: confirmationEnvelope.version,
expectedEffect: confirmationEnvelope.expectedEffect,
summary: confirmationEnvelope.summary,
request: confirmationEnvelope.request,
} : null,
execution: executionStateFromResult(result),
raw: result,
}
}
function executeResponseFromResult(result) {
const action = actionFromResult(result)
const request = requestFromResult(result)
return {
ok: Boolean(result?.ok),
phase: 'execute',
decision: result?.decision || 'execution_unknown',
reason: result?.reason || null,
action,
execution: executionStateFromResult(result),
request: request ? {
method: request.method,
path: request.path,
query: request.query || null,
body: request.body ?? null,
} : null,
result: result?.result || null,
confirmation: result?.confirmationEnvelope ? {
token: result.confirmationEnvelope.token,
version: result.confirmationEnvelope.version,
expectedEffect: result.confirmationEnvelope.expectedEffect,
} : null,
raw: result,
}
}
export async function previewAssistantAction(input = {}, options = {}) {
// Preview must build the write plan and confirmation envelope without doing I/O.
// The actual execute phase still requires the returned confirmation token.
const dryRun = await executeAssistantAction({
...input,
confirmed: true,
}, {
...options,
planPreview: true,
mode: 'dry-run',
allowNetworkExecution: false,
})
return previewFromDryRun(dryRun)
}
export async function executeAssistantCallerAction(input = {}, options = {}) {
const executed = await executeAssistantAction({
...input,
confirmed: true,
}, {
...options,
mode: 'execute',
allowNetworkExecution: true,
confirmationToken: options.confirmationToken || input.confirmationToken || input.confirmation?.token || null,
})
return executeResponseFromResult(executed)
}
export async function handleAssistantCallerRequest(payload = {}, options = {}) {
const phase = payload.phase || payload.mode || 'preview'
const input = payload.input || payload
if (phase === 'preview' || phase === 'dry-run') {
return previewAssistantAction(input, options)
}
if (phase === 'execute') {
return executeAssistantCallerAction(input, {
...options,
confirmationToken: payload.confirmationToken || input.confirmationToken || options.confirmationToken || null,
})
}
return {
ok: false,
phase,
decision: 'caller_phase_unknown',
reason: `Unsupported assistant caller phase: ${phase}`,
}
}
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex >= 0) {
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) throw new Error('--input-json requires a path')
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
}
const inlineIndex = process.argv.indexOf('--input')
if (inlineIndex >= 0) {
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
}
return null
}
function readArg(name) {
const index = process.argv.indexOf(name)
if (index === -1) return null
return process.argv[index + 1] || null
}
function assertSmoke(condition, message) {
if (!condition) throw new Error(message)
}
async function runSmoke() {
const validation = await validateCatalog()
assertSmoke(validation.ok, 'catalog must validate before caller smoke')
const writeInput = {
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
idempotencyKey: 'smoke:caller:block:user_pupa',
}
const preview = await previewAssistantAction(writeInput)
const executeWithoutToken = await executeAssistantCallerAction(writeInput, {
baseUrl: 'http://launcher.local.test',
launcherInternalToken: 'smoke-token',
fetchImpl: async () => {
throw new Error('fetch must not run without confirmation token')
},
})
const mockCalls = []
const executeWithToken = await executeAssistantCallerAction({
...writeInput,
confirmationToken: preview.confirmation?.token,
}, {
baseUrl: 'http://launcher.local.test',
launcherInternalToken: 'smoke-token',
fetchImpl: async (url, init) => {
mockCalls.push({ url: String(url), init })
return {
ok: true,
status: 200,
statusText: 'OK',
headers: { get: () => 'application/json' },
text: async () => JSON.stringify({ ok: true, source: 'mock-launcher' }),
}
},
})
const readPreview = await previewAssistantAction({
actionId: 'hub.user.read_admin_summary',
assistantRole: 'admin',
groups: ['nodedc:superadmin'],
actorUserId: 'user_root',
})
assertSmoke(preview.decision === 'preview_ready', 'write preview should be ready')
assertSmoke(Boolean(preview.confirmation?.token), 'write preview should include confirmation token')
assertSmoke(preview.execution.requiresUserConfirmation, 'write preview should require user confirmation')
assertSmoke(executeWithoutToken.reason === 'write_confirmation_envelope_missing', 'execute without token must be blocked')
assertSmoke(executeWithToken.decision === 'executed', 'execute with token should call adapter')
assertSmoke(mockCalls.length === 1, 'execute with token should issue one request')
assertSmoke(readPreview.decision === 'preview_ready', 'read preview should be ready')
assertSmoke(readPreview.confirmation === null, 'read preview should not require confirmation token')
return {
ok: true,
validation: validation.counts,
cases: {
preview,
executeWithoutToken,
executeWithToken,
readPreview,
},
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const payload = await readInputJson()
if (!payload) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
const output = await handleAssistantCallerRequest(payload, {
baseUrl: readArg('--base-url') || process.env.NDC_LAUNCHER_BASE_URL || null,
confirmationToken: readArg('--confirmation-token') || null,
launcherInternalToken:
readArg('--launcher-internal-token') ||
readArg('--internal-token') ||
process.env.NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN ||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
null,
})
console.log(JSON.stringify(output, null, 2))
}
}
@@ -0,0 +1,994 @@
import fs from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { buildHubLauncherAdminPlan } from './adapters/hub-launcher-admin.mjs'
import { buildOpsProductPlan } from './adapters/ops-product-api.mjs'
import { resolveAssistantAction } from './assistant-action-resolver.mjs'
import { validateCatalog } from './validate.mjs'
const WRITE_METHODS = new Set(['PATCH', 'POST', 'PUT'])
const HUB_LAUNCHER_ADMIN_ADAPTER_ID = 'adapter.hub.launcher_admin_api'
const OPS_PRODUCT_API_ADAPTER_ID = 'adapter.ops.product_api'
const ASSISTANT_GATEWAY_NAME = 'ontology-core'
const CONFIRMATION_ENVELOPE_VERSION = 'assistant-write-confirmation-v1'
const ALLOWED_ROUTES = [
{ method: 'GET', pattern: /^\/api\/admin\/control-plane$/ },
{ method: 'PATCH', pattern: /^\/api\/admin\/users\/[^/?#]+\/profile$/ },
{ method: 'PATCH', pattern: /^\/api\/admin\/memberships\/[^/?#]+$/ },
]
const OPS_ALLOWED_ROUTES = [
{ method: 'GET', pattern: /^\/api\/v1\/tools\/issues$/ },
{ method: 'POST', pattern: /^\/api\/v1\/tools\/issues$/ },
{ method: 'POST', pattern: /^\/api\/v1\/tools\/issues\/[^/?#]+\/comments$/ },
]
function headerValue(headers, key) {
const wanted = key.toLowerCase()
for (const [candidateKey, value] of Object.entries(headers || {})) {
if (candidateKey.toLowerCase() === wanted) return value
}
return null
}
function normalizeMethod(method) {
return String(method || '').trim().toUpperCase()
}
function isAllowedRoute(method, path) {
return ALLOWED_ROUTES.some((route) => route.method === method && route.pattern.test(path))
}
function isAllowedOpsRoute(method, path) {
return OPS_ALLOWED_ROUTES.some((route) => route.method === method && route.pattern.test(path))
}
function appendQuery(url, query = {}) {
for (const [key, value] of Object.entries(query || {})) {
if (value === null || value === undefined || value === '') continue
url.searchParams.set(key, String(value))
}
}
function buildRequestUrl(baseUrl, request) {
if (!baseUrl) throw new Error('baseUrl is required for network execution')
const url = new URL(request.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
appendQuery(url, request.query)
return url
}
function readResponseHeader(headers, key) {
if (!headers) return ''
if (typeof headers.get === 'function') return headers.get(key) || ''
return headerValue(headers, key) || ''
}
function normalizeHeaderString(value) {
return String(value || '').trim()
}
function stableJson(value) {
if (Array.isArray(value)) {
return `[${value.map((item) => stableJson(item)).join(',')}]`
}
if (value && typeof value === 'object') {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(',')}}`
}
return JSON.stringify(value)
}
function confirmationDigest(payload) {
return createHash('sha256').update(stableJson(payload)).digest('hex').slice(0, 24)
}
function buildWriteConfirmationEnvelope(plan) {
const request = plan?.request || {}
const method = normalizeMethod(request.method)
if (!WRITE_METHODS.has(method)) return null
const action = plan?.actionDecision?.action || {}
const actor = plan?.gatewayActor || {}
const payload = {
version: CONFIRMATION_ENVELOPE_VERSION,
actionId: action.id || null,
app: action.app || null,
adapterId: plan?.adapterId || null,
actor: {
userId: actor.userId || null,
email: actor.email || null,
subject: actor.subject || null,
},
request: {
method,
path: String(request.path || ''),
body: request.body ?? null,
idempotencyKey: headerValue(request.headers, 'Idempotency-Key') || null,
},
}
return {
...payload,
token: confirmationDigest(payload),
riskLevel: action.riskLevel || null,
confirmationMode: action.confirmationMode || null,
expectedEffect: action.summary || request.summary || null,
summary: request.summary || null,
}
}
function readConfirmationToken(input = {}, options = {}) {
return normalizeHeaderString(
options.confirmationToken ||
input.confirmationToken ||
input.confirmation?.token ||
input.confirmationEnvelope?.token,
)
}
function verifyWriteConfirmationEnvelope(input, options, envelope) {
if (!envelope) return { ok: true }
const providedToken = readConfirmationToken(input, options)
if (!providedToken) {
return {
ok: false,
reason: 'write_confirmation_envelope_missing',
}
}
if (providedToken !== envelope.token) {
return {
ok: false,
reason: 'write_confirmation_envelope_mismatch',
}
}
return { ok: true }
}
function buildBearerHeader(token) {
const value = normalizeHeaderString(token)
if (!value) return ''
return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`
}
function buildHubLauncherGatewayHeaders(plan, options = {}) {
if (plan?.adapterId !== HUB_LAUNCHER_ADMIN_ADAPTER_ID) {
return { ok: true, headers: {} }
}
const token = normalizeHeaderString(options.launcherInternalToken || options.internalAccessToken)
if (!token) {
return {
ok: false,
reason: 'launcher_internal_token_missing',
}
}
const actor = options.gatewayActor || plan.gatewayActor || {}
const actorUserId = normalizeHeaderString(actor.userId)
const actorEmail = normalizeHeaderString(actor.email)
const actorSubject = normalizeHeaderString(actor.subject)
if (!actorUserId && !actorEmail && !actorSubject) {
return {
ok: false,
reason: 'assistant_gateway_actor_missing',
}
}
return {
ok: true,
headers: {
Authorization: buildBearerHeader(token),
'X-NODEDC-Assistant-Gateway': ASSISTANT_GATEWAY_NAME,
...(actorUserId ? { 'X-NODEDC-Assistant-Actor-User-Id': actorUserId } : {}),
...(actorEmail ? { 'X-NODEDC-Assistant-Actor-Email': actorEmail } : {}),
...(actorSubject ? { 'X-NODEDC-Assistant-Actor-Subject': actorSubject } : {}),
},
}
}
function endpointOrigin(value) {
try {
return new URL(value).origin
} catch {
return ''
}
}
function firstArray(...values) {
for (const value of values) {
if (Array.isArray(value)) return value
}
return []
}
function entitlementOpsGrant(payload) {
const grants = payload?.appGrants || payload?.grant || payload?.appGrant || payload?.entitlement || payload?.entitlements
if (grants?.ops && typeof grants.ops === 'object') return grants.ops
if (payload?.ops && typeof payload.ops === 'object') return payload.ops
return null
}
function authorizationFromOpsGrant(grant) {
const servers = firstArray(grant?.mcpServers, grant?.mcp_servers)
for (const server of servers) {
const headers = server?.httpHeaders || server?.headers || {}
const authorization = normalizeHeaderString(headerValue(headers, 'Authorization'))
if (authorization) return authorization
}
return ''
}
function omitEmptyObject(value = {}) {
const out = {}
for (const [key, raw] of Object.entries(value || {})) {
const text = normalizeHeaderString(raw)
if (text) out[key] = text
}
return out
}
async function resolveOpsAuthorization(plan, options = {}) {
const explicit = buildBearerHeader(options.opsRunToken || options.opsGatewayToken)
if (explicit) return { ok: true, authorization: explicit }
const entitlementUrl = normalizeHeaderString(options.opsEntitlementUrl)
const entitlementAuthorization = normalizeHeaderString(
options.opsEntitlementAuthorization || buildBearerHeader(options.opsEntitlementToken),
)
if (!entitlementUrl || !entitlementAuthorization) {
return {
ok: false,
reason: 'ops_entitlement_not_configured',
}
}
const actor = options.gatewayActor || plan.gatewayActor || {}
const opsContext = plan.opsContext || {}
const fetchImpl = options.fetchImpl || globalThis.fetch
if (typeof fetchImpl !== 'function') {
throw new Error('No fetch implementation available')
}
const owner = omitEmptyObject({
userId: actor.userId,
email: actor.email,
ownerKey: actor.subject,
})
const ops = omitEmptyObject({
workspaceSlug: opsContext.workspaceSlug,
projectId: opsContext.projectId,
})
const response = await fetchImpl(entitlementUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: entitlementAuthorization,
},
body: JSON.stringify({
schemaVersion: 'ai-workspace.entitlement-request.v1',
appId: 'ops',
owner,
runContext: {
...(Object.keys(ops).length ? { ops } : {}),
...omitEmptyObject({
opsWorkspaceSlug: opsContext.workspaceSlug,
opsProjectId: opsContext.projectId,
}),
},
activeContext: {},
requestedAt: new Date().toISOString(),
}),
})
const payload = await readResponsePayload(response)
if (!response.ok || payload?.ok === false) {
return {
ok: false,
reason: payload?.error || payload?.message || `ops_entitlement_http_${response.status}`,
}
}
const grant = entitlementOpsGrant(payload)
const authorization = authorizationFromOpsGrant(grant)
if (!authorization) {
return {
ok: false,
reason: grant?.status || 'ops_entitlement_token_missing',
grantStatus: grant?.status || null,
grantContext: grant?.context || null,
}
}
return { ok: true, authorization }
}
async function buildGatewayHeaders(plan, options = {}) {
if (plan?.adapterId === HUB_LAUNCHER_ADMIN_ADAPTER_ID) {
return buildHubLauncherGatewayHeaders(plan, options)
}
if (plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
const resolved = await resolveOpsAuthorization(plan, options)
if (!resolved.ok) return resolved
return {
ok: true,
headers: {
Authorization: resolved.authorization,
Accept: 'application/json',
},
}
}
return { ok: true, headers: {} }
}
async function readResponsePayload(response) {
const raw = typeof response.text === 'function' ? await response.text() : ''
const contentType = readResponseHeader(response.headers, 'content-type')
if (!raw) return null
if (contentType.includes('application/json')) {
try {
return JSON.parse(raw)
} catch {
return raw
}
}
return raw
}
function planActionId(plan) {
return String(plan?.actionDecision?.action?.id || '').trim()
}
function dataArray(payload, key) {
const value = payload?.data?.[key]
return Array.isArray(value) ? value : []
}
function isPendingLikeStatus(value) {
const status = String(value || '').trim().toLowerCase()
return ['new', 'pending', 'requested', 'open', 'waiting', 'submitted'].includes(status)
}
function compactAccessRequest(item = {}) {
return {
id: item.id || null,
email: item.email || null,
name: [item.lastName, item.firstName, item.middleName].filter(Boolean).join(' ').trim() || null,
company: item.company || null,
phone: item.phone || null,
status: item.status || null,
targetClientId: item.targetClientId || null,
role: item.role || null,
createdAt: item.createdAt || null,
updatedAt: item.updatedAt || null,
}
}
function compactInvite(item = {}) {
return {
id: item.id || null,
email: item.email || item.inviteeEmail || null,
status: item.status || null,
clientId: item.clientId || item.targetClientId || null,
role: item.role || null,
createdAt: item.createdAt || null,
updatedAt: item.updatedAt || null,
}
}
function compactUser(item = {}) {
return {
id: item.id || null,
name: item.name || null,
email: item.email || null,
globalStatus: item.globalStatus || null,
}
}
function compactMembership(item = {}) {
return {
id: item.id || null,
clientId: item.clientId || null,
userId: item.userId || null,
role: item.role || null,
status: item.status || null,
coreAssistantRole: item.coreAssistantRole || null,
}
}
function firstIssueArray(payload) {
return firstArray(
payload?.issues,
payload?.items,
payload?.results,
payload?.data?.issues,
payload?.data?.items,
payload?.data?.results,
Array.isArray(payload?.data) ? payload.data : null,
)
}
function firstObject(...values) {
for (const value of values) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value
}
return null
}
function issueTimestamp(item = {}) {
const value = item.updatedAt || item.updated_at || item.createdAt || item.created_at || item.created || item.updated
const time = Date.parse(value)
return Number.isFinite(time) ? time : 0
}
function compactIssue(item = {}) {
const state = item.state && typeof item.state === 'object' ? item.state : null
const project = item.project && typeof item.project === 'object' ? item.project : null
return {
id: item.id || item.issue_id || null,
identifier: item.identifier || item.sequence_id || item.issueIdentifier || item.issue_identifier || null,
title: item.title || item.name || item.subject || null,
description: item.description || null,
state: state?.name || item.stateName || item.state_name || item.state || null,
priority: item.priority || null,
projectId: item.project_id || item.projectId || project?.id || null,
projectName: item.project_name || item.projectName || project?.name || null,
createdAt: item.createdAt || item.created_at || item.created || null,
updatedAt: item.updatedAt || item.updated_at || item.updated || null,
url: item.url || item.web_url || item.webUrl || null,
}
}
function compactComment(item = {}) {
return {
id: item.id || item.comment_id || item.commentId || null,
issueId: item.issue_id || item.issueId || item.issue?.id || null,
body: item.body || item.comment || item.text || null,
createdAt: item.createdAt || item.created_at || item.created || null,
updatedAt: item.updatedAt || item.updated_at || item.updated || null,
}
}
function normalizeOpsPayload(plan, payload) {
if (!payload || typeof payload !== 'object') return payload
const actionId = planActionId(plan)
if (actionId === 'ops.card.create') {
const issue = firstObject(
payload.issue,
payload.card,
payload.data?.issue,
payload.data?.card,
payload.data,
)
return {
card: issue ? compactIssue(issue) : null,
source: 'ops-agent-gateway',
context: plan.opsContext || null,
rawOk: payload.ok ?? null,
}
}
if (actionId === 'ops.card.add_comment') {
const comment = firstObject(
payload.comment,
payload.issueComment,
payload.data?.comment,
payload.data?.issueComment,
payload.data,
)
return {
comment: comment ? compactComment(comment) : null,
source: 'ops-agent-gateway',
context: plan.opsContext || null,
rawOk: payload.ok ?? null,
}
}
if (actionId !== 'ops.card.list_recent') return payload
const limit = Number(plan?.opsContext?.limit || 5)
const issues = firstIssueArray(payload)
.slice()
.sort((a, b) => issueTimestamp(b) - issueTimestamp(a))
.slice(0, Math.max(1, Math.min(20, Number.isFinite(limit) ? limit : 5)))
.map(compactIssue)
return {
counts: {
cards: issues.length,
},
latestCard: issues[0] || null,
cards: issues,
source: 'ops-agent-gateway',
context: plan.opsContext || null,
}
}
function normalizeExecutionPayload(plan, payload) {
if (plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
return normalizeOpsPayload(plan, payload)
}
return normalizeControlPlanePayload(plan, payload)
}
function normalizeControlPlanePayload(plan, payload) {
if (!payload || typeof payload !== 'object') return payload
const actionId = planActionId(plan)
const counts = payload.counts && typeof payload.counts === 'object' ? payload.counts : {}
const accessRequests = dataArray(payload, 'accessRequests')
const pendingAccessRequests = accessRequests.filter((item) => isPendingLikeStatus(item?.status))
const engineRequests = dataArray(payload, 'engineWorkflowAccessRequests')
const pendingEngineRequests = engineRequests.filter((item) => isPendingLikeStatus(item?.status))
const invites = dataArray(payload, 'invites')
const pendingInvites = invites.filter((item) => isPendingLikeStatus(item?.status))
if (actionId === 'hub.access_request.list_pending') {
return {
actor: payload.actor || null,
counts: {
accessRequests: counts.accessRequests ?? accessRequests.length,
engineWorkflowAccessRequests: counts.engineWorkflowAccessRequests ?? engineRequests.length,
},
summary: {
accessRequests: accessRequests.length,
pendingAccessRequests: pendingAccessRequests.length,
engineWorkflowAccessRequests: engineRequests.length,
pendingEngineWorkflowAccessRequests: pendingEngineRequests.length,
newAccessRequests: pendingAccessRequests.map((item) => item.email).filter(Boolean),
},
accessRequests: pendingAccessRequests.map(compactAccessRequest),
engineWorkflowAccessRequests: pendingEngineRequests.map(compactAccessRequest),
}
}
if (actionId === 'hub.invite.list_pending') {
return {
actor: payload.actor || null,
counts: {
invites: counts.invites ?? invites.length,
},
summary: {
invites: invites.length,
pendingInvites: pendingInvites.length,
pendingInviteEmails: pendingInvites.map((item) => item.email || item.inviteeEmail).filter(Boolean),
},
invites: pendingInvites.map(compactInvite),
}
}
if (actionId === 'hub.user.read_admin_summary') {
return {
actor: payload.actor || null,
counts,
summary: {
users: counts.users ?? dataArray(payload, 'users').length,
memberships: counts.memberships ?? dataArray(payload, 'memberships').length,
services: counts.services ?? dataArray(payload, 'services').length,
grants: counts.grants ?? dataArray(payload, 'grants').length,
invites: counts.invites ?? invites.length,
accessRequests: counts.accessRequests ?? accessRequests.length,
pendingAccessRequests: pendingAccessRequests.length,
newAccessRequests: pendingAccessRequests.map((item) => item.email).filter(Boolean),
},
users: dataArray(payload, 'users').map(compactUser),
memberships: dataArray(payload, 'memberships').map(compactMembership),
accessRequests: pendingAccessRequests.map(compactAccessRequest),
}
}
return payload
}
export function validateExecutionPlan(plan) {
const errors = []
const warnings = []
const request = plan?.request || {}
const method = normalizeMethod(request.method)
const path = String(request.path || '')
const adapterId = plan?.adapterId || plan?.actionDecision?.action?.adapterId || ''
if (!plan?.ok) {
errors.push('plan_not_ok')
}
if (!method) {
errors.push('request_method_missing')
}
if (method === 'DELETE') {
errors.push('delete_method_forbidden')
}
if (adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
if (!path.startsWith('/api/v1/tools/')) {
errors.push('request_path_not_ops_tools_api')
}
if (!['GET', 'POST'].includes(method)) {
errors.push(`method_not_allowed:${method || 'empty'}`)
}
if (method && path && !isAllowedOpsRoute(method, path)) {
errors.push(`route_not_allowlisted:${method} ${path}`)
}
if (method === 'GET' && !request.query?.project_id) {
errors.push('ops_project_id_missing')
}
if (method === 'POST' && !request.body?.project_id) {
errors.push('ops_project_id_missing')
}
} else {
if (!path.startsWith('/api/admin/')) {
errors.push('request_path_not_admin_api')
}
if (!['GET', 'PATCH'].includes(method)) {
errors.push(`method_not_allowed:${method || 'empty'}`)
}
if (method && path && !isAllowedRoute(method, path)) {
errors.push(`route_not_allowlisted:${method} ${path}`)
}
}
if (WRITE_METHODS.has(method) && !headerValue(request.headers, 'Idempotency-Key')) {
errors.push('write_requires_idempotency_key')
}
if (WRITE_METHODS.has(method) && plan?.safety?.confirmationAlreadyChecked !== true) {
errors.push('write_requires_prior_confirmation_check')
}
if (plan?.actionDecision?.action?.riskLevel === 'destructive') {
errors.push('destructive_action_forbidden')
}
if (plan?.actionDecision?.decision === 'forbidden') {
errors.push('forbidden_action_decision')
}
if (plan?.safety?.noDeleteRouteMapped !== true) {
warnings.push('no_delete_route_mapping_not_asserted')
}
if (plan?.safety?.sourceGuardedRoutesOnly !== true) {
warnings.push('source_guarded_routes_only_not_asserted')
}
return {
ok: errors.length === 0,
errors,
warnings,
method,
path,
}
}
async function executeHttpPlan(plan, options) {
const request = plan.request
const method = normalizeMethod(request.method)
const url = buildRequestUrl(options.baseUrl, request)
const headers = {
...(request.headers || {}),
...(options.headers || {}),
}
const init = {
method,
headers,
}
if (method !== 'GET' && request.body !== undefined) {
if (!headerValue(headers, 'Content-Type')) {
headers['Content-Type'] = 'application/json'
}
if (!headerValue(headers, 'Accept')) {
headers.Accept = 'application/json'
}
init.body = JSON.stringify(request.body)
}
const fetchImpl = options.fetchImpl || globalThis.fetch
if (typeof fetchImpl !== 'function') {
throw new Error('No fetch implementation available')
}
const response = await fetchImpl(url, init)
const payload = normalizeExecutionPayload(plan, await readResponsePayload(response))
return {
ok: Boolean(response.ok),
status: response.status,
statusText: response.statusText || '',
url: String(url),
method,
payload,
}
}
async function buildAssistantExecutionPlan(input = {}, options = {}) {
const decision = options.decision || await resolveAssistantAction(input, options)
const adapterId = decision?.action?.adapterId
if (adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
return buildOpsProductPlan(input, { ...options, decision })
}
return buildHubLauncherAdminPlan(input, { ...options, decision })
}
function executionBaseUrlForPlan(plan, options = {}) {
if (plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
return normalizeHeaderString(options.opsGatewayBaseUrl) || endpointOrigin(options.opsEntitlementUrl)
}
return normalizeHeaderString(options.baseUrl)
}
export async function executeAssistantAction(input = {}, options = {}) {
const mode = options.mode || 'dry-run'
const plan = options.plan || await buildAssistantExecutionPlan(input, options)
const safety = validateExecutionPlan(plan)
const confirmationEnvelope = buildWriteConfirmationEnvelope(plan)
if (!safety.ok) {
return {
ok: false,
decision: 'execution_blocked',
reason: 'execution_safety_check_failed',
safety,
confirmationEnvelope,
plan,
}
}
if (mode !== 'execute') {
return {
ok: true,
decision: 'dry_run',
reason: 'network_execution_not_requested',
safety,
confirmationEnvelope,
plan,
result: null,
}
}
if (!options.allowNetworkExecution) {
return {
ok: false,
decision: 'execution_blocked',
reason: 'network_execution_not_enabled',
safety,
confirmationEnvelope,
plan,
}
}
if ((plan.dryRunOnly || plan.adapterStatus !== 'implemented') && !options.allowPlannedAdapterExecution) {
return {
ok: false,
decision: 'execution_blocked',
reason: 'adapter_not_marked_implemented',
safety,
confirmationEnvelope,
plan,
}
}
const baseUrl = executionBaseUrlForPlan(plan, options)
if (!baseUrl) {
return {
ok: false,
decision: 'execution_blocked',
reason: plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID ? 'ops_gateway_base_url_missing' : 'base_url_missing',
safety,
confirmationEnvelope,
plan,
}
}
const confirmationCheck = verifyWriteConfirmationEnvelope(input, options, confirmationEnvelope)
if (!confirmationCheck.ok) {
return {
ok: false,
decision: 'execution_blocked',
reason: confirmationCheck.reason,
safety,
confirmationEnvelope,
plan,
}
}
const gatewayHeaders = await buildGatewayHeaders(plan, options)
if (!gatewayHeaders.ok) {
return {
ok: false,
decision: 'execution_blocked',
reason: gatewayHeaders.reason,
safety,
confirmationEnvelope,
plan,
}
}
const result = await executeHttpPlan(plan, {
...options,
baseUrl,
headers: {
...(options.headers || {}),
...gatewayHeaders.headers,
},
})
return {
ok: result.ok,
decision: result.ok ? 'executed' : 'execution_failed',
safety,
confirmationEnvelope,
plan,
result,
}
}
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex >= 0) {
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) throw new Error('--input-json requires a path')
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
}
const inlineIndex = process.argv.indexOf('--input')
if (inlineIndex >= 0) {
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
}
return null
}
function readArg(name) {
const index = process.argv.indexOf(name)
if (index === -1) return null
return process.argv[index + 1] || null
}
function assertSmoke(condition, message) {
if (!condition) throw new Error(message)
}
async function runSmoke() {
const validation = await validateCatalog()
assertSmoke(validation.ok, 'catalog must validate before executor smoke')
const blockInput = {
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
idempotencyKey: 'smoke:execute:block:user_pupa',
}
const dryRun = await executeAssistantAction(blockInput)
const executeWithoutConfirmation = await executeAssistantAction(blockInput, {
mode: 'execute',
allowNetworkExecution: true,
baseUrl: 'http://launcher.local.test',
})
const confirmedBlockInput = {
...blockInput,
confirmationToken: dryRun.confirmationEnvelope?.token,
}
const executeBlocked = await executeAssistantAction(confirmedBlockInput, {
mode: 'execute',
allowNetworkExecution: true,
baseUrl: 'http://launcher.local.test',
})
const mockCalls = []
const mockFetch = async (url, init) => {
mockCalls.push({ url: String(url), init })
return {
ok: true,
status: 200,
statusText: 'OK',
headers: { get: () => 'application/json' },
text: async () => JSON.stringify({ ok: true, source: 'mock-launcher' }),
}
}
const mockExecuted = await executeAssistantAction(confirmedBlockInput, {
mode: 'execute',
allowNetworkExecution: true,
baseUrl: 'http://launcher.local.test',
launcherInternalToken: 'smoke-token',
fetchImpl: mockFetch,
})
const deleteBlocked = await executeAssistantAction({
actionId: 'hub.user.delete',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}, {
mode: 'execute',
allowNetworkExecution: true,
allowPlannedAdapterExecution: true,
baseUrl: 'http://launcher.local.test',
fetchImpl: mockFetch,
})
const badDeletePlanSafety = validateExecutionPlan({
ok: true,
adapterStatus: 'implemented',
dryRunOnly: false,
safety: {
noDeleteRouteMapped: false,
sourceGuardedRoutesOnly: false,
confirmationAlreadyChecked: true,
},
actionDecision: {
decision: 'allowed',
action: { riskLevel: 'privileged' },
},
request: {
method: 'DELETE',
path: '/api/admin/users/user_pupa',
headers: { 'Idempotency-Key': 'bad-delete' },
},
})
assertSmoke(dryRun.decision === 'dry_run', 'default executor mode must be dry_run')
assertSmoke(Boolean(dryRun.confirmationEnvelope?.token), 'dry-run write should return confirmation envelope token')
assertSmoke(executeWithoutConfirmation.reason === 'write_confirmation_envelope_missing', 'write execution must require confirmation envelope token')
assertSmoke(executeBlocked.reason === 'launcher_internal_token_missing', 'confirmed implemented gateway adapter must require an internal token')
assertSmoke(mockExecuted.decision === 'executed', 'mock execution should execute with gateway auth headers')
assertSmoke(mockCalls.length === 1, 'mock execution should issue exactly one request')
assertSmoke(mockCalls[0].init.method === 'PATCH', 'mock execution should use PATCH')
assertSmoke(mockCalls[0].url === 'http://launcher.local.test/api/admin/users/user_pupa/profile', 'mock execution should hit profile route')
assertSmoke(mockCalls[0].init.headers.Authorization === 'Bearer smoke-token', 'mock execution should send internal bearer token')
assertSmoke(mockCalls[0].init.headers['X-NODEDC-Assistant-Gateway'] === ASSISTANT_GATEWAY_NAME, 'mock execution should send assistant gateway header')
assertSmoke(mockCalls[0].init.headers['X-NODEDC-Assistant-Actor-User-Id'] === 'user_root', 'mock execution should send actor user id')
assertSmoke(deleteBlocked.decision === 'execution_blocked', 'delete action must block before network execution')
assertSmoke(!badDeletePlanSafety.ok && badDeletePlanSafety.errors.includes('delete_method_forbidden'), 'DELETE plan must fail safety check')
return {
ok: true,
validation: validation.counts,
cases: {
dryRun,
executeWithoutConfirmation,
executeBlocked,
mockExecuted,
deleteBlocked,
badDeletePlanSafety,
},
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const input = await readInputJson()
if (!input) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
const allowPlannedAdapterExecution =
process.argv.includes('--allow-planned-adapter-execution') &&
process.env.NDC_ASSISTANT_EXECUTOR_UNSAFE_DEV === '1'
const output = await executeAssistantAction(input, {
mode: process.argv.includes('--execute') ? 'execute' : 'dry-run',
allowNetworkExecution: process.argv.includes('--execute'),
allowPlannedAdapterExecution,
baseUrl: readArg('--base-url') || process.env.NDC_LAUNCHER_BASE_URL || null,
confirmationToken: readArg('--confirmation-token') || null,
launcherInternalToken:
readArg('--launcher-internal-token') ||
readArg('--internal-token') ||
process.env.NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN ||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
null,
})
console.log(JSON.stringify(output, null, 2))
}
}
@@ -0,0 +1,78 @@
import fs from 'node:fs/promises'
import { buildHubLauncherAdminPlan } from './adapters/hub-launcher-admin.mjs'
import { validateCatalog } from './validate.mjs'
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex >= 0) {
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) throw new Error('--input-json requires a path')
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
}
const inlineIndex = process.argv.indexOf('--input')
if (inlineIndex >= 0) {
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
}
return null
}
function assertSmoke(condition, message) {
if (!condition) throw new Error(message)
}
async function runSmoke() {
const validation = await validateCatalog()
assertSmoke(validation.ok, 'catalog must validate before adapter plan smoke')
const cases = {
blockUser: await buildHubLauncherAdminPlan({
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
idempotencyKey: 'smoke:block:user_pupa',
}),
assistantRole: await buildHubLauncherAdminPlan({
actionId: 'hub.assistant_access.change_role',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
membershipId: 'mem_client_public_pool_user_pupa',
targetAssistantRole: 'blocked',
confirmed: true,
idempotencyKey: 'smoke:assistant-role:user_pupa',
}),
deleteUser: await buildHubLauncherAdminPlan({
actionId: 'hub.user.delete',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
}
assertSmoke(cases.blockUser.ok, 'block user should produce a HUB adapter plan')
assertSmoke(cases.blockUser.request.method === 'PATCH', 'block user should use PATCH')
assertSmoke(cases.blockUser.request.body.globalStatus === 'blocked', 'block user should set globalStatus=blocked')
assertSmoke(cases.assistantRole.ok, 'assistant role change should produce a HUB adapter plan')
assertSmoke(cases.assistantRole.request.body.coreAssistantRole === 'blocked', 'assistant role should set coreAssistantRole=blocked')
assertSmoke(!cases.deleteUser.ok, 'delete user must not produce an adapter plan')
return { ok: true, validation: validation.counts, cases }
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const input = await readInputJson()
if (!input) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
console.log(JSON.stringify(await buildHubLauncherAdminPlan(input), null, 2))
}
}
@@ -0,0 +1,331 @@
import fs from 'node:fs/promises'
import { loadCatalog } from './catalog.mjs'
import { resolveAssistantAccess } from './assistant-policy.mjs'
import { validateCatalog } from './validate.mjs'
function normalizeText(value) {
return String(value || '').trim().toLowerCase()
}
function tokenize(value) {
return normalizeText(value)
.split(/[^a-zа-яё0-9_]+/iu)
.filter(Boolean)
}
function unique(values) {
return [...new Set(values)]
}
function actionSearchText(action) {
return [
action.id,
action.app,
action.domain,
action.summary,
...(action.intentAliases || []),
].join(' ')
}
function scoreAction(action, input) {
if (input.actionId && input.actionId === action.id) return 1000
const query = normalizeText(input.intent || input.term || input.text || input.query)
if (!query) return 0
const aliases = (action.intentAliases || []).map(normalizeText)
if (aliases.includes(query)) return 200
if (aliases.some((alias) => alias && query.includes(alias))) return 160
const queryTokens = new Set(tokenize(query))
if (!queryTokens.size) return 0
const actionTokens = new Set(tokenize(actionSearchText(action)))
let score = 0
for (const token of queryTokens) {
if (actionTokens.has(token)) score += 10
}
if (input.app && input.app === action.app) score += 20
return score
}
function findAction(catalog, input) {
const candidates = catalog.assistantActions.actions
.map((action) => ({ action, score: scoreAction(action, input) }))
.filter((candidate) => candidate.score > 0)
.sort((a, b) => b.score - a.score || a.action.id.localeCompare(b.action.id))
return {
selected: candidates[0]?.action || null,
candidates: candidates.slice(0, 5).map(({ action, score }) => ({
id: action.id,
app: action.app,
riskLevel: action.riskLevel,
confirmationMode: action.confirmationMode,
adapterStatus: action.adapterStatus,
score,
summary: action.summary,
})),
}
}
function collectSafeAlternativeIds(action, riskPolicy) {
const ids = [...(action.safeAlternativeActionIds || [])]
for (const denyId of action.hardDenyIds || []) {
const deny = riskPolicy.hardDenies.find((candidate) => candidate.id === denyId)
ids.push(...(deny?.safeAlternatives || []))
}
return unique(ids.filter((id) => id !== action.id))
}
function describeAlternatives(ids, actionById) {
return ids.map((id) => {
const action = actionById.get(id)
return {
id,
app: action?.app || null,
riskLevel: action?.riskLevel || null,
confirmationMode: action?.confirmationMode || null,
summary: action?.summary || null,
}
})
}
function missingCapabilities(action, access) {
const allowed = new Set(access.allowedCapabilities || [])
return (action.capabilityIds || []).filter((capabilityId) => !allowed.has(capabilityId))
}
function isSelfTarget(input) {
if (!input.actorUserId || !input.targetUserId) return false
return String(input.actorUserId) === String(input.targetUserId)
}
function makeDecision(decision, action, input, access, extra = {}) {
return {
ok: decision === 'allowed',
decision,
action: action ? {
id: action.id,
app: action.app,
domain: action.domain,
riskLevel: action.riskLevel,
confirmationMode: action.confirmationMode,
selfActionPolicy: action.selfActionPolicy,
adapterId: action.adapterId,
adapterStatus: action.adapterStatus,
requiredScopes: action.requiredScopes || [],
capabilityIds: action.capabilityIds || [],
summary: action.summary,
} : null,
actor: {
assistantRole: input.assistantRole || null,
effectiveRole: access?.effectiveRole || null,
adminScope: access?.adminScope || null,
},
execution: action ? {
policyAllowed: decision === 'allowed',
adapterReady: action.adapterStatus === 'implemented',
executionReady: decision === 'allowed' && action.adapterStatus === 'implemented',
requiresConfirmation: action.confirmationMode === 'explicit' || action.confirmationMode === 'typed',
confirmed: Boolean(input.confirmed),
} : null,
...extra,
}
}
export async function resolveAssistantAction(input = {}, options = {}) {
const catalog = options.catalog || await loadCatalog()
const actionById = new Map(catalog.assistantActions.actions.map((action) => [action.id, action]))
const riskPolicy = catalog.assistantRiskPolicy
const found = findAction(catalog, input)
const action = found.selected
if (!action) {
return {
ok: false,
decision: 'action_not_registered',
reason: 'Request did not resolve to a registered assistant action card.',
candidates: found.candidates,
}
}
const access = await resolveAssistantAccess(input, { policy: catalog.assistantAccessPolicy })
const safeAlternativeIds = collectSafeAlternativeIds(action, riskPolicy)
const safeAlternatives = describeAlternatives(safeAlternativeIds, actionById)
if (action.riskLevel === 'destructive' || action.confirmationMode === 'forbidden' || action.adapterStatus === 'forbidden') {
return makeDecision('forbidden', action, input, access, {
reason: 'destructive_or_forbidden_action',
refusal: action.refusal || 'Assistant action is forbidden by risk policy.',
hardDenyIds: action.hardDenyIds || [],
safeAlternatives,
candidates: found.candidates,
})
}
if (action.selfActionPolicy === 'blocked' && isSelfTarget(input)) {
return makeDecision('denied', action, input, access, {
reason: 'self_targeting_blocked',
hardDenyIds: action.hardDenyIds || [],
safeAlternatives,
candidates: found.candidates,
})
}
if (action.selfActionPolicy === 'no_self_lockout' && isSelfTarget(input)) {
const removesOwnAccess = Boolean(input.removesOwnAccess || input.targetRole === 'assistant_blocked' || input.targetStatus === 'disabled' || input.globalStatus === 'blocked')
const hasAlternativeAdminPath = Boolean(input.hasAlternativeAdminPath)
if (removesOwnAccess && !hasAlternativeAdminPath) {
return makeDecision('denied', action, input, access, {
reason: 'self_lockout_denied',
hardDenyIds: unique([...(action.hardDenyIds || []), 'assistant.action.deny.self_lockout']),
safeAlternatives,
candidates: found.candidates,
})
}
}
const missing = missingCapabilities(action, access)
if (missing.length) {
return makeDecision('denied', action, input, access, {
reason: 'assistant_role_missing_capability',
missingCapabilities: missing,
safeAlternatives,
candidates: found.candidates,
})
}
if (action.riskLevel === 'privileged' && access.effectiveRole !== 'assistant_admin') {
return makeDecision('denied', action, input, access, {
reason: 'privileged_action_requires_assistant_admin',
safeAlternatives,
candidates: found.candidates,
})
}
if (action.adapterStatus === 'future') {
return makeDecision('future_adapter', action, input, access, {
reason: 'app_owned_adapter_not_available_yet',
safeAlternatives,
candidates: found.candidates,
})
}
if ((action.confirmationMode === 'explicit' || action.confirmationMode === 'typed') && !input.confirmed) {
return makeDecision('needs_confirmation', action, input, access, {
reason: 'explicit_confirmation_required',
confirmationRequest: {
actionId: action.id,
app: action.app,
targetUserId: input.targetUserId || null,
expectedEffect: action.summary,
},
safeAlternatives,
candidates: found.candidates,
})
}
return makeDecision('allowed', action, input, access, {
reason: action.adapterStatus === 'implemented' ? 'policy_and_adapter_ready' : 'policy_allowed_adapter_not_implemented',
safeAlternatives,
candidates: found.candidates,
})
}
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex >= 0) {
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) throw new Error('--input-json requires a path')
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
}
const inlineIndex = process.argv.indexOf('--input')
if (inlineIndex >= 0) {
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
}
return null
}
function assertSmoke(condition, message) {
if (!condition) throw new Error(message)
}
async function runSmoke() {
const validation = await validateCatalog()
assertSmoke(validation.ok, 'catalog must validate before assistant action smoke')
const cases = {
deleteUser: await resolveAssistantAction({
actionId: 'hub.user.delete',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
blockAsMember: await resolveAssistantAction({
actionId: 'hub.user.block',
assistantRole: 'member',
membershipRole: 'member',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
blockNeedsConfirmation: await resolveAssistantAction({
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
}),
blockAllowed: await resolveAssistantAction({
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
selfLockout: await resolveAssistantAction({
actionId: 'hub.membership.disable',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_root',
removesOwnAccess: true,
hasAlternativeAdminPath: false,
confirmed: true,
}),
futureEngineAdapter: await resolveAssistantAction({
actionId: 'engine.workflow.share',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
}
assertSmoke(cases.deleteUser.decision === 'forbidden', 'delete user must be forbidden')
assertSmoke(cases.blockAsMember.decision === 'denied', 'member must not block users')
assertSmoke(cases.blockNeedsConfirmation.decision === 'needs_confirmation', 'privileged write must require confirmation')
assertSmoke(cases.blockAllowed.decision === 'allowed', 'confirmed assistant admin block should be policy-allowed')
assertSmoke(cases.blockAllowed.execution.executionReady, 'implemented HUB allowlist adapter should be execution-ready after policy')
assertSmoke(cases.selfLockout.decision === 'denied', 'self lockout must be denied')
assertSmoke(cases.futureEngineAdapter.decision === 'future_adapter', 'future engine adapter must not execute')
return { ok: true, validation: validation.counts, cases }
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const input = await readInputJson()
if (!input) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
console.log(JSON.stringify(await resolveAssistantAction(input), null, 2))
}
}
@@ -0,0 +1,195 @@
import { readJson } from './catalog.mjs'
const LAUNCHER_ROOT_GROUPS = new Set(['nodedc:superadmin', 'nodedc:launcher:admin'])
const LAUNCHER_ROOT_ROLES = new Set(['root_admin'])
const LAUNCHER_CLIENT_ADMIN_ROLES = new Set(['client_owner', 'client_admin'])
export async function loadAssistantAccessPolicy() {
return readJson('catalog/assistant-access-policy.json')
}
function normalizeString(value) {
return String(value || '').trim()
}
function normalizeRole(value, policy) {
const raw = normalizeString(value).toLowerCase()
if (!raw) return ''
const roleIds = new Set(policy.roleVocabulary.map((role) => role.id))
if (roleIds.has(raw)) return raw
const alias = policy.roleAliases.find((candidate) => candidate.alias.toLowerCase() === raw)
return alias?.roleId || raw
}
function toStringSet(value) {
if (!Array.isArray(value)) return new Set()
return new Set(value.map((item) => String(item)))
}
function resolveLauncherAdminScope(input) {
const groups = toStringSet(input.groups)
const launcherGlobalRole = normalizeString(input.launcherGlobalRole)
const membershipRole = normalizeString(input.membershipRole)
const membershipStatus = normalizeString(input.membershipStatus || 'active')
const isRoot = Boolean(input.isRoot || input.isSuperAdmin || LAUNCHER_ROOT_ROLES.has(launcherGlobalRole) || [...groups].some((group) => LAUNCHER_ROOT_GROUPS.has(group)))
const isClientAdmin = membershipStatus === 'active' && LAUNCHER_CLIENT_ADMIN_ROLES.has(membershipRole)
return {
isRoot,
isClientAdmin,
present: isRoot || isClientAdmin,
reason: isRoot ? 'launcher_root_scope' : isClientAdmin ? 'launcher_client_admin_scope' : 'no_launcher_admin_scope',
}
}
function uniqueSorted(values) {
return [...new Set(values)].sort()
}
export async function resolveAssistantAccess(input, options = {}) {
const policy = options.policy || await loadAssistantAccessPolicy()
const launcherUserStatus = normalizeString(input.launcherUserStatus || input.globalStatus || 'active')
const membershipStatus = normalizeString(input.membershipStatus || 'active')
const adminScope = resolveLauncherAdminScope(input)
let requestedRole = normalizeRole(input.assistantRole, policy)
if (!requestedRole) {
requestedRole = adminScope.isRoot
? policy.defaultResolution.superAdminDefaultRole
: policy.defaultResolution.activeUserDefaultRole
}
const forcedBlocked = launcherUserStatus === 'blocked' || membershipStatus === 'disabled'
const effectiveRole = forcedBlocked ? policy.defaultResolution.blockedUserEffectiveRole : requestedRole
const matrix = policy.roleMatrix.find((entry) => entry.roleId === effectiveRole)
if (!matrix) {
throw new Error(`Unknown assistant role: ${effectiveRole}`)
}
const allCapabilities = policy.capabilities.map((capability) => capability.id)
let allowedCapabilities = matrix.deniedCapabilities?.includes('*') ? [] : [...(matrix.allowedCapabilities || [])]
const deniedCapabilities = new Set(matrix.deniedCapabilities?.includes('*') ? allCapabilities : matrix.deniedCapabilities || [])
const limitations = []
for (const conditional of matrix.conditionalCapabilities || []) {
if (conditional.requires === 'launcher_admin_scope.root' && adminScope.isRoot) {
allowedCapabilities.push(conditional.capabilityId)
deniedCapabilities.delete(conditional.capabilityId)
} else {
deniedCapabilities.add(conditional.capabilityId)
limitations.push({
capabilityId: conditional.capabilityId,
reason: conditional.requires,
})
}
}
if (effectiveRole === 'assistant_admin' && !adminScope.present) {
for (const capabilityId of matrix.deniedWhenNoAdminScope || []) {
deniedCapabilities.add(capabilityId)
allowedCapabilities = allowedCapabilities.filter((candidate) => candidate !== capabilityId)
}
limitations.push({
reason: 'assistant_admin_requires_existing_launcher_admin_scope',
})
}
if (forcedBlocked) {
allowedCapabilities = []
for (const capabilityId of allCapabilities) {
deniedCapabilities.add(capabilityId)
}
limitations.push({
reason: launcherUserStatus === 'blocked' ? 'launcher_user_blocked' : 'membership_disabled',
})
}
allowedCapabilities = uniqueSorted(allowedCapabilities.filter((capabilityId) => !deniedCapabilities.has(capabilityId)))
return {
ok: true,
requestedRole,
effectiveRole,
visible: Boolean(matrix.visible && !forcedBlocked),
canUse: Boolean(matrix.canUse && !forcedBlocked),
launcherUserStatus,
membershipStatus,
adminScope,
allowedCapabilities,
deniedCapabilities: uniqueSorted([...deniedCapabilities]),
limitations,
hardDenies: policy.hardDenies.map((deny) => deny.id),
}
}
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex === -1) return null
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) {
throw new Error('--input-json requires a path')
}
return readJson(inputPath)
}
function assertSmoke(condition, message) {
if (!condition) {
throw new Error(message)
}
}
async function runSmoke() {
const policy = await loadAssistantAccessPolicy()
const member = await resolveAssistantAccess({
assistantRole: 'member',
launcherUserStatus: 'active',
membershipRole: 'member',
}, { policy })
const clientAdmin = await resolveAssistantAccess({
assistantRole: 'admin',
launcherUserStatus: 'active',
membershipRole: 'client_admin',
membershipStatus: 'active',
}, { policy })
const blocked = await resolveAssistantAccess({
assistantRole: 'admin',
launcherUserStatus: 'blocked',
membershipRole: 'client_admin',
}, { policy })
const superAdmin = await resolveAssistantAccess({
groups: ['nodedc:superadmin'],
launcherUserStatus: 'active',
}, { policy })
assertSmoke(member.visible, 'member assistant should be visible')
assertSmoke(!member.allowedCapabilities.includes('assistant.manage_users'), 'member assistant must not manage users')
assertSmoke(clientAdmin.allowedCapabilities.includes('assistant.manage_users'), 'client assistant admin should manage users inside Launcher scope')
assertSmoke(!clientAdmin.allowedCapabilities.includes('assistant.manage_service_catalog'), 'client assistant admin must not get root service catalog capability')
assertSmoke(!blocked.visible && !blocked.canUse, 'blocked assistant must be hidden and unusable')
assertSmoke(superAdmin.effectiveRole === 'assistant_admin', 'superadmin should default to assistant_admin')
assertSmoke(superAdmin.allowedCapabilities.includes('assistant.manage_service_catalog'), 'superadmin should receive root conditional capabilities')
return {
ok: true,
cases: { member, clientAdmin, blocked, superAdmin },
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const input = await readInputJson()
if (!input) {
throw new Error('Use --smoke or --input-json <path>')
}
console.log(JSON.stringify(await resolveAssistantAccess(input), null, 2))
}
}
+76
View File
@@ -0,0 +1,76 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export const serviceRoot = path.resolve(__dirname, '..')
export const catalogRoot = path.join(serviceRoot, 'catalog')
export async function readJson(relativePath) {
const fullPath = path.join(serviceRoot, relativePath)
const raw = await fs.readFile(fullPath, 'utf8')
return JSON.parse(raw)
}
export async function loadCatalog() {
const [
entities,
relations,
aliases,
guardrails,
evidence,
resolverRules,
assistantAccessPolicy,
assistantActions,
assistantRiskPolicy,
] = await Promise.all([
readJson('catalog/entities.json'),
readJson('catalog/relations.json'),
readJson('catalog/aliases.json'),
readJson('catalog/guardrails.json'),
readJson('catalog/evidence.json'),
readJson('catalog/resolver-rules.json'),
readJson('catalog/assistant-access-policy.json'),
readJson('catalog/assistant-actions.json'),
readJson('catalog/assistant-risk-policy.json'),
])
const contextBindings = await readJson('catalog/context-bindings.json')
const entityById = new Map(entities.entities.map((entity) => [entity.id, entity]))
const relationById = new Map(relations.relations.map((relation) => [relation.id, relation]))
const contextById = new Map(contextBindings.contexts.map((context) => [context.id, context]))
const bindingTypeById = new Map(contextBindings.bindingTypes.map((bindingType) => [bindingType.id, bindingType]))
const aliasByKey = new Map(
aliases.aliases.map((alias) => [normalizeAlias(alias.alias), alias.canonicalId]),
)
return {
entities,
relations,
aliases,
guardrails,
evidence,
resolverRules,
assistantAccessPolicy,
assistantActions,
assistantRiskPolicy,
contextBindings,
entityById,
relationById,
contextById,
bindingTypeById,
aliasByKey,
}
}
export function normalizeAlias(value) {
return String(value || '').trim().toLowerCase()
}
export function canonicalizeEntityId(value, catalog) {
const raw = String(value || '').trim()
if (!raw) return ''
if (catalog.entityById.has(raw)) return raw
return catalog.aliasByKey.get(normalizeAlias(raw)) || raw
}
+280
View File
@@ -0,0 +1,280 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadCatalog, serviceRoot } from './catalog.mjs'
import { validateCatalog } from './validate.mjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const contextBindingsPath = path.resolve(__dirname, '..', 'catalog', 'context-bindings.json')
function usage() {
return [
'Usage:',
' node src/registry.mjs list-contexts',
' node src/registry.mjs list-bindings',
' node src/registry.mjs add-context --input-json examples/engine-context.example.json [--dry-run]',
' node src/registry.mjs add-binding --input-json examples/ops-engine-binding.example.json [--dry-run]',
' node src/registry.mjs import-engine-context --input-json examples/engine-workflow-manifest.example.json [--dry-run]',
].join('\n')
}
function argValue(name) {
const index = process.argv.indexOf(name)
if (index < 0) return ''
return process.argv[index + 1] || ''
}
async function readInputJson() {
const inputPath = argValue('--input-json')
const inputRaw = argValue('--input')
if (inputRaw) return JSON.parse(inputRaw)
if (!inputPath) throw new Error('missing --input-json or --input')
const fullPath = path.resolve(process.cwd(), inputPath)
return JSON.parse(await fs.readFile(fullPath, 'utf8'))
}
async function readContextBindings() {
return JSON.parse(await fs.readFile(contextBindingsPath, 'utf8'))
}
async function writeContextBindings(next) {
const raw = `${JSON.stringify(next, null, 2)}\n`
await fs.writeFile(contextBindingsPath, raw, 'utf8')
}
function required(value, label) {
if (value === undefined || value === null || String(value).trim() === '') {
throw new Error(`${label} is required`)
}
}
function isObject(value) {
return value && typeof value === 'object' && !Array.isArray(value)
}
function slugPart(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9а-яё]+/giu, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64) || 'unknown'
}
function validateDraftContext(draft, catalog, registry) {
required(draft.id, 'context.id')
required(draft.surface, 'context.surface')
required(draft.entityId, 'context.entityId')
required(draft.sourceSystem, 'context.sourceSystem')
required(draft.label, 'context.label')
required(draft.status, 'context.status')
if (!catalog.entityById.has(draft.entityId)) throw new Error(`unknown context.entityId: ${draft.entityId}`)
if (!registry.contextStatuses.includes(draft.status)) throw new Error(`invalid context.status: ${draft.status}`)
if (!isObject(draft.refs)) throw new Error('context.refs must be an object')
if (draft.parentContextId && !catalog.contextById.has(draft.parentContextId)) {
throw new Error(`unknown context.parentContextId: ${draft.parentContextId}`)
}
}
function validateDraftBinding(draft, catalog, registry) {
required(draft.id, 'binding.id')
required(draft.typeId, 'binding.typeId')
required(draft.status, 'binding.status')
required(draft.fromContextId, 'binding.fromContextId')
required(draft.toContextId, 'binding.toContextId')
if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`)
if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`)
if (!catalog.contextById.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`)
if (!catalog.contextById.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`)
}
function validateDraftBindingWithKnownContextIds(draft, catalog, registry, knownContextIds) {
required(draft.id, 'binding.id')
required(draft.typeId, 'binding.typeId')
required(draft.status, 'binding.status')
required(draft.fromContextId, 'binding.fromContextId')
required(draft.toContextId, 'binding.toContextId')
if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`)
if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`)
if (!knownContextIds.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`)
if (!knownContextIds.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`)
}
function upsertById(list, item) {
const index = list.findIndex((existing) => existing.id === item.id)
if (index >= 0) {
const next = [...list]
next[index] = item
return { list: next, action: 'updated' }
}
return { list: [...list, item], action: 'created' }
}
async function updateRegistry(mutator, dryRun) {
const beforeRaw = await fs.readFile(contextBindingsPath, 'utf8')
const registry = JSON.parse(beforeRaw)
const next = structuredClone(registry)
const result = mutator(next)
if (dryRun) return { dryRun: true, ...result, registry: next }
await writeContextBindings(next)
const validation = await validateCatalog()
if (!validation.ok) {
await fs.writeFile(contextBindingsPath, beforeRaw, 'utf8')
const err = new Error('registry validation failed; reverted context-bindings.json')
err.validation = validation
throw err
}
return { dryRun: false, ...result, validation: validation.counts }
}
async function listContexts() {
const catalog = await loadCatalog()
return catalog.contextBindings.contexts.map((context) => ({
id: context.id,
surface: context.surface,
entityId: context.entityId,
sourceSystem: context.sourceSystem,
label: context.label,
status: context.status,
refs: context.refs,
parentContextId: context.parentContextId || null,
}))
}
async function listBindings() {
const catalog = await loadCatalog()
return catalog.contextBindings.bindings.map((binding) => ({
id: binding.id,
typeId: binding.typeId,
status: binding.status,
fromContextId: binding.fromContextId,
toContextId: binding.toContextId,
summary: binding.summary || '',
}))
}
async function addContext(dryRun) {
const draft = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
validateDraftContext(draft, catalog, registry)
return updateRegistry((next) => {
const out = upsertById(next.contexts, draft)
next.contexts = out.list
return { action: out.action, context: draft }
}, dryRun)
}
async function addBinding(dryRun) {
const draft = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
validateDraftBinding(draft, catalog, registry)
return updateRegistry((next) => {
const out = upsertById(next.bindings, draft)
next.bindings = out.list
return { action: out.action, binding: draft }
}, dryRun)
}
function buildEngineContextFromManifest(manifest) {
if (!isObject(manifest)) throw new Error('manifest must be an object')
const workflow = manifest.workflow || {}
required(workflow.id, 'workflow.id')
required(workflow.label, 'workflow.label')
const refs = {
workflow_id: String(workflow.id),
}
if (workflow.nodeId) refs.node_id = String(workflow.nodeId)
if (workflow.runtimeWorkflowId) refs.runtime_workflow_id = String(workflow.runtimeWorkflowId)
if (workflow.n8nInstanceId) refs.n8n_instance_id = String(workflow.n8nInstanceId)
return {
id: `context.engine.${slugPart(workflow.id)}`,
surface: 'engine',
entityId: workflow.runtimeWorkflowId ? 'engine.workflow_l2' : 'engine.workflow_l1',
sourceSystem: String(manifest.context?.sourceSystem || 'nodedc-engine'),
label: String(workflow.label),
status: String(manifest.context?.status || 'planned'),
refs,
}
}
function buildOpsEngineBindingFromManifest(manifest, engineContext) {
const bind = manifest.bindToOps || {}
if (bind.enabled !== true) return null
required(bind.fromContextId, 'bindToOps.fromContextId')
return {
id: `binding.ops.${slugPart(bind.fromContextId)}.engine.${slugPart(engineContext.id)}`,
typeId: 'binding_type.ops_card_to_engine_workflow',
status: String(bind.status || 'planned'),
fromContextId: String(bind.fromContextId),
toContextId: engineContext.id,
summary: String(bind.summary || `Binds ${bind.fromContextId} to ${engineContext.id}.`),
}
}
async function importEngineContext(dryRun) {
const manifest = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
const engineContext = buildEngineContextFromManifest(manifest)
const binding = buildOpsEngineBindingFromManifest(manifest, engineContext)
validateDraftContext(engineContext, catalog, registry)
if (binding) {
const knownContextIds = new Set([
...catalog.contextById.keys(),
engineContext.id,
])
validateDraftBindingWithKnownContextIds(binding, catalog, registry, knownContextIds)
}
return updateRegistry((next) => {
const contextOut = upsertById(next.contexts, engineContext)
next.contexts = contextOut.list
let bindingOut = null
if (binding) {
bindingOut = upsertById(next.bindings, binding)
next.bindings = bindingOut.list
}
return {
action: bindingOut ? `${contextOut.action}_context_${bindingOut.action}_binding` : `${contextOut.action}_context`,
context: engineContext,
binding,
}
}, dryRun)
}
async function main() {
const command = process.argv[2]
const dryRun = process.argv.includes('--dry-run')
let output
if (command === 'list-contexts') output = await listContexts()
else if (command === 'list-bindings') output = await listBindings()
else if (command === 'add-context') output = await addContext(dryRun)
else if (command === 'add-binding') output = await addBinding(dryRun)
else if (command === 'import-engine-context') output = await importEngineContext(dryRun)
else {
console.error(usage())
process.exit(2)
}
console.log(JSON.stringify({ ok: true, serviceRoot, command, output }, null, 2))
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(JSON.stringify({
ok: false,
error: err.message || String(err),
validation: err.validation || undefined,
}, null, 2))
process.exit(1)
})
}
+212
View File
@@ -0,0 +1,212 @@
import fs from 'node:fs/promises'
import { canonicalizeEntityId, loadCatalog } from './catalog.mjs'
import { validateCatalog } from './validate.mjs'
function tokenize(value) {
return String(value || '')
.toLowerCase()
.split(/[^a-zа-яё0-9_]+/iu)
.filter(Boolean)
}
function scoreRule(rule, input) {
let score = 0
const entityId = input.entityId || ''
if (entityId && rule.inputEntityIds.includes(entityId)) score += 10
if (input.surface && input.surface === rule.fromSurface) score += 6
if (score === 0) return 0
const intentTokens = new Set(tokenize(input.intent))
for (const hint of rule.intentHints || []) {
const hintTokens = tokenize(hint)
if (hintTokens.some((token) => intentTokens.has(token))) score += 2
}
return score
}
function refMatches(candidateRefs = {}, inputRefs = {}) {
const candidateEntries = Object.entries(candidateRefs || {}).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
const inputEntries = Object.entries(inputRefs || {}).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
if (!candidateEntries.length || !inputEntries.length) return false
const matches = (entries, refs) => entries.every(([key, value]) => String(refs[key] || '') === String(value))
const candidateInsideInput = matches(candidateEntries, inputRefs)
const inputInsideCandidate = matches(inputEntries, candidateRefs)
if (!candidateInsideInput && !inputInsideCandidate) return false
const matchedKeys = candidateEntries
.map(([key]) => key)
.filter((key) => inputRefs[key] !== undefined && String(inputRefs[key]) === String(candidateRefs[key]))
const hasStrongRef = ['issue_id', 'workflow_id', 'runtime_workflow_id', 'node_id', 'grant_id'].some((key) => matchedKeys.includes(key))
return hasStrongRef || matchedKeys.length >= 2
}
function contextScore(context, input) {
let score = 0
if (input.contextId && input.contextId === context.id) score += 100
if (refMatches(context.refs, input.refs)) score += 40
if (score === 0) return 0
if (input.surface && input.surface === context.surface) score += 10
if (input.entityId && input.entityId === context.entityId) score += 10
return score
}
function findMatchingContexts(catalog, input) {
return catalog.contextBindings.contexts
.map((context) => ({ context, score: contextScore(context, input) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.context.id.localeCompare(b.context.id))
}
function bindingTouchesContext(binding, contextIds) {
return contextIds.has(binding.fromContextId) || contextIds.has(binding.toContextId)
}
function findBindingsForContexts(catalog, contexts) {
const contextIds = new Set(contexts.map(({ context }) => context.id))
return catalog.contextBindings.bindings
.filter((binding) => bindingTouchesContext(binding, contextIds))
.map((binding) => ({
...binding,
type: catalog.bindingTypeById.get(binding.typeId) || null,
fromContext: catalog.contextById.get(binding.fromContextId) || null,
toContext: catalog.contextById.get(binding.toContextId) || null,
}))
}
function findRequiredBindingTypes(catalog, selectedRule) {
if (!selectedRule) return []
return catalog.contextBindings.bindingTypes.filter((bindingType) => bindingType.relationId === selectedRule.relationId)
}
export async function resolveContext(input = {}) {
const catalog = await loadCatalog()
const entityId = canonicalizeEntityId(input.entityId || input.alias || input.term, catalog)
const normalizedInput = {
...input,
entityId,
}
const matchedContexts = findMatchingContexts(catalog, normalizedInput)
if (!normalizedInput.entityId && matchedContexts[0]?.context?.entityId) {
normalizedInput.entityId = matchedContexts[0].context.entityId
}
const candidates = catalog.resolverRules.rules
.map((rule) => ({ rule, score: scoreRule(rule, normalizedInput) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.rule.id.localeCompare(b.rule.id))
const selected = candidates[0]?.rule || null
const bindings = findBindingsForContexts(catalog, matchedContexts)
const requiredBindingTypes = findRequiredBindingTypes(catalog, selected)
const existingBindingTypeIds = new Set(bindings.map((binding) => binding.typeId))
const missingBindingTypes = requiredBindingTypes.filter((bindingType) => !existingBindingTypeIds.has(bindingType.id))
return {
input: normalizedInput,
canonicalEntity: catalog.entityById.get(normalizedInput.entityId) || null,
matchedContexts: matchedContexts.map(({ context, score }) => ({
id: context.id,
score,
surface: context.surface,
entityId: context.entityId,
label: context.label,
sourceSystem: context.sourceSystem,
refs: context.refs,
status: context.status,
})),
bindings: bindings.map((binding) => ({
id: binding.id,
typeId: binding.typeId,
status: binding.status,
fromContextId: binding.fromContextId,
toContextId: binding.toContextId,
relationId: binding.type?.relationId || null,
summary: binding.summary || binding.type?.summary || '',
})),
missingBindingTypes: missingBindingTypes.map((bindingType) => ({
id: bindingType.id,
relationId: bindingType.relationId,
requiredFromRefs: bindingType.requiredFromRefs || [],
requiredToRefs: bindingType.requiredToRefs || [],
summary: bindingType.summary,
})),
selectedRule: selected,
candidates: candidates.map(({ rule, score }) => ({
id: rule.id,
score,
outputSurface: rule.outputSurface,
outputEntityIds: rule.outputEntityIds,
requiredBindings: rule.requiredBindings,
summary: rule.summary,
})),
}
}
async function resolveFromCli() {
const inputIndex = process.argv.indexOf('--input')
const inputJsonIndex = process.argv.indexOf('--input-json')
let input = null
if (inputIndex >= 0) {
input = JSON.parse(process.argv[inputIndex + 1] || '{}')
} else if (inputJsonIndex >= 0) {
const raw = await fs.readFile(process.argv[inputJsonIndex + 1], 'utf8')
input = JSON.parse(raw)
}
if (!input) {
console.error('Usage: node src/resolver.mjs --input-json examples/ops-card-to-engine-request.json')
process.exit(2)
}
const out = await resolveContext(input)
console.log(JSON.stringify(out, null, 2))
}
async function runSmoke() {
const validation = await validateCatalog()
if (!validation.ok) {
console.error(JSON.stringify(validation, null, 2))
process.exit(1)
}
const samples = [
{ surface: 'ops', alias: 'issue', intent: 'inspect engine workflow for this card' },
{
surface: 'ops',
alias: 'issue',
intent: 'inspect engine workflow for this ontology card',
refs: {
workspace_slug: 'nodedc',
project_id: '86629a11-eaff-4ad2-9f89-e5245a344fcc',
issue_id: '1312519a-0eda-4ea0-9e34-2113c3724ecf',
},
},
{ surface: 'engine', entityId: 'engine.workflow_l1', intent: 'создай карточку в ops по текущему workflow' },
{ surface: 'assistant', entityId: 'assistant.bridge', intent: 'select mcp gateway grant and tool' },
{ term: 'n8n workflow id', intent: 'runtime workflow id lookup' },
]
const results = []
for (const sample of samples) {
const out = await resolveContext(sample)
results.push({
sample,
canonicalEntityId: out.canonicalEntity?.id || out.input.entityId || null,
selectedRuleId: out.selectedRule?.id || null,
outputSurface: out.selectedRule?.outputSurface || null,
outputEntityIds: out.selectedRule?.outputEntityIds || [],
matchedContextIds: out.matchedContexts.map((context) => context.id),
missingBindingTypeIds: out.missingBindingTypes.map((bindingType) => bindingType.id),
})
}
console.log(JSON.stringify({ ok: true, validation: validation.counts, results }, null, 2))
}
if (import.meta.url === `file://${process.argv[1]}` && process.argv.includes('--smoke')) {
await runSmoke()
} else if (import.meta.url === `file://${process.argv[1]}`) {
await resolveFromCli()
}
+363
View File
@@ -0,0 +1,363 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { loadCatalog, serviceRoot } from './catalog.mjs'
const ID_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$/
const RULE_ID_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/
const ROLE_ID_RE = /^[a-z][a-z0-9_]*$/
const ALLOWED_RELATION_STATUSES = new Set([
'source-confirmed',
'source-evidenced',
'owner-confirmed',
'product-required',
'future-concept',
'tech-debt-noncanonical',
'pending',
])
function assert(condition, message, errors) {
if (!condition) errors.push(message)
}
function assertUnique(ids, label, errors) {
const seen = new Set()
for (const id of ids) {
assert(!seen.has(id), `${label} duplicate id: ${id}`, errors)
seen.add(id)
}
}
function assertEntityRefs(refs, entityById, label, errors) {
for (const ref of refs || []) {
assert(entityById.has(ref), `${label} references unknown entity: ${ref}`, errors)
}
}
function assertRelationRef(ref, relationById, label, errors) {
assert(relationById.has(ref), `${label} references unknown relation: ${ref}`, errors)
}
function assertContextRefs(refs, contextById, label, errors) {
for (const ref of refs || []) {
assert(contextById.has(ref), `${label} references unknown context: ${ref}`, errors)
}
}
function assertRefsObject(refs, label, errors) {
assert(refs && typeof refs === 'object' && !Array.isArray(refs), `${label} refs must be object`, errors)
for (const [key, value] of Object.entries(refs || {})) {
assert(/^[a-z][a-z0-9_]*$/i.test(key), `${label} refs has invalid key: ${key}`, errors)
assert(value !== null && value !== undefined && String(value).trim() !== '', `${label} refs.${key} is empty`, errors)
}
}
async function pathExists(relativePath) {
try {
await fs.access(path.join(serviceRoot, relativePath))
return true
} catch {
return false
}
}
export async function validateCatalog() {
const catalog = await loadCatalog()
const errors = []
const warnings = []
const {
entities,
relations,
aliases,
guardrails,
evidence,
resolverRules,
assistantAccessPolicy,
assistantActions,
assistantRiskPolicy,
contextBindings,
entityById,
relationById,
contextById,
bindingTypeById,
} = catalog
const statusVocabulary = new Set(entities.statusVocabulary || [])
assertUnique(entities.entities.map((entity) => entity.id), 'entity', errors)
for (const entity of entities.entities) {
assert(ID_RE.test(entity.id), `entity id has invalid format: ${entity.id}`, errors)
assert(entity.name, `entity ${entity.id} missing name`, errors)
assert(entity.surface, `entity ${entity.id} missing surface`, errors)
assert(Array.isArray(entity.status) && entity.status.length > 0, `entity ${entity.id} missing status`, errors)
for (const status of entity.status || []) {
assert(statusVocabulary.has(status), `entity ${entity.id} uses unknown status: ${status}`, errors)
}
}
assertUnique(relations.relations.map((relation) => relation.id), 'relation', errors)
for (const relation of relations.relations) {
assert(RULE_ID_RE.test(relation.id), `relation id has invalid format: ${relation.id}`, errors)
assert(ALLOWED_RELATION_STATUSES.has(relation.status), `relation ${relation.id} uses unknown status: ${relation.status}`, errors)
assertEntityRefs(relation.from, entityById, `relation ${relation.id}.from`, errors)
assertEntityRefs(relation.to, entityById, `relation ${relation.id}.to`, errors)
}
assertUnique(aliases.aliases.map((alias) => alias.alias.toLowerCase()), 'alias', errors)
for (const alias of aliases.aliases) {
assert(alias.alias, 'alias missing alias text', errors)
assert(entityById.has(alias.canonicalId), `alias ${alias.alias} points to unknown entity: ${alias.canonicalId}`, errors)
}
assertUnique(guardrails.rules.map((rule) => rule.id), 'guardrail', errors)
for (const rule of guardrails.rules) {
assert(RULE_ID_RE.test(rule.id), `guardrail id has invalid format: ${rule.id}`, errors)
assert(['error', 'warning'].includes(rule.severity), `guardrail ${rule.id} invalid severity`, errors)
assertEntityRefs(rule.entityIds, entityById, `guardrail ${rule.id}`, errors)
}
for (const pair of guardrails.blockedConflations || []) {
assert(Array.isArray(pair) && pair.length === 2, `blocked conflation must be a pair: ${JSON.stringify(pair)}`, errors)
assertEntityRefs(pair, entityById, 'blocked conflation', errors)
}
for (const ledger of evidence.ledgers || []) {
assert(await pathExists(ledger.path), `evidence ledger path missing: ${ledger.path}`, errors)
assertEntityRefs(ledger.entityIds, entityById, `ledger ${ledger.id}`, errors)
}
for (const baselineDoc of evidence.baselineDocs || []) {
assert(await pathExists(baselineDoc), `baseline doc path missing: ${baselineDoc}`, errors)
}
assertUnique(resolverRules.rules.map((rule) => rule.id), 'resolver rule', errors)
for (const rule of resolverRules.rules) {
assert(RULE_ID_RE.test(rule.id), `resolver rule id has invalid format: ${rule.id}`, errors)
assert(rule.fromSurface, `resolver rule ${rule.id} missing fromSurface`, errors)
assert(rule.outputSurface, `resolver rule ${rule.id} missing outputSurface`, errors)
assertRelationRef(rule.relationId, relationById, `resolver rule ${rule.id}`, errors)
assertEntityRefs(rule.inputEntityIds, entityById, `resolver rule ${rule.id}.inputEntityIds`, errors)
assertEntityRefs(rule.outputEntityIds, entityById, `resolver rule ${rule.id}.outputEntityIds`, errors)
if (rule.status !== 'product-required') {
warnings.push(`resolver rule ${rule.id} has non-product-required status: ${rule.status}`)
}
}
const assistantRoleIds = new Set((assistantAccessPolicy.roleVocabulary || []).map((role) => role.id))
const assistantCapabilityIds = new Set((assistantAccessPolicy.capabilities || []).map((capability) => capability.id))
const assistantOperationIds = new Set((assistantAccessPolicy.operations || []).map((operation) => operation.id))
const assistantHardDenyIds = new Set((assistantAccessPolicy.hardDenies || []).map((deny) => deny.id))
const assistantRiskLevelIds = new Set((assistantRiskPolicy.riskLevels || []).map((riskLevel) => riskLevel.id))
const assistantConfirmationModeIds = new Set((assistantRiskPolicy.confirmationModes || []).map((mode) => mode.id))
const assistantSelfActionPolicyIds = new Set((assistantRiskPolicy.selfActionPolicies || []).map((policy) => policy.id))
const assistantActionHardDenyIds = new Set((assistantRiskPolicy.hardDenies || []).map((deny) => deny.id))
const assistantAppIds = new Set((assistantActions.apps || []).map((app) => app.id))
const assistantAdapterStatuses = new Set(assistantActions.adapterStatuses || [])
const assistantActionIds = new Set((assistantActions.actions || []).map((action) => action.id))
assertUnique([...assistantRoleIds], 'assistant role', errors)
for (const role of assistantAccessPolicy.roleVocabulary || []) {
assert(ROLE_ID_RE.test(role.id), `assistant role id has invalid format: ${role.id}`, errors)
assert(role.name, `assistant role ${role.id} missing name`, errors)
assert(role.summary, `assistant role ${role.id} missing summary`, errors)
}
assertUnique((assistantAccessPolicy.roleAliases || []).map((alias) => alias.alias.toLowerCase()), 'assistant role alias', errors)
for (const alias of assistantAccessPolicy.roleAliases || []) {
assert(alias.alias, 'assistant role alias missing alias text', errors)
assert(assistantRoleIds.has(alias.roleId), `assistant role alias ${alias.alias} references unknown role: ${alias.roleId}`, errors)
}
for (const [key, roleId] of Object.entries(assistantAccessPolicy.defaultResolution || {})) {
if (key.endsWith('Role')) {
assert(assistantRoleIds.has(roleId), `assistant defaultResolution.${key} references unknown role: ${roleId}`, errors)
}
}
for (const sourceAuthority of assistantAccessPolicy.sourceAuthorities || []) {
assert(RULE_ID_RE.test(sourceAuthority.id), `assistant source authority id has invalid format: ${sourceAuthority.id}`, errors)
assertEntityRefs(sourceAuthority.entityIds, entityById, `assistant source authority ${sourceAuthority.id}`, errors)
}
assertUnique([...assistantCapabilityIds], 'assistant capability', errors)
for (const capability of assistantAccessPolicy.capabilities || []) {
assert(RULE_ID_RE.test(capability.id), `assistant capability id has invalid format: ${capability.id}`, errors)
assert(entityById.has(capability.entityId), `assistant capability ${capability.id} references unknown entity: ${capability.entityId}`, errors)
assert(capability.summary, `assistant capability ${capability.id} missing summary`, errors)
}
assertUnique((assistantAccessPolicy.roleMatrix || []).map((matrix) => matrix.roleId), 'assistant role matrix', errors)
for (const matrix of assistantAccessPolicy.roleMatrix || []) {
assert(assistantRoleIds.has(matrix.roleId), `assistant matrix references unknown role: ${matrix.roleId}`, errors)
for (const capabilityId of matrix.allowedCapabilities || []) {
assert(assistantCapabilityIds.has(capabilityId), `assistant matrix ${matrix.roleId} allows unknown capability: ${capabilityId}`, errors)
}
for (const capabilityId of matrix.deniedCapabilities || []) {
if (capabilityId === '*') continue
assert(assistantCapabilityIds.has(capabilityId), `assistant matrix ${matrix.roleId} denies unknown capability: ${capabilityId}`, errors)
}
for (const capabilityId of matrix.deniedWhenNoAdminScope || []) {
assert(assistantCapabilityIds.has(capabilityId), `assistant matrix ${matrix.roleId} no-scope denies unknown capability: ${capabilityId}`, errors)
}
for (const conditional of matrix.conditionalCapabilities || []) {
assert(assistantCapabilityIds.has(conditional.capabilityId), `assistant matrix ${matrix.roleId} conditional unknown capability: ${conditional.capabilityId}`, errors)
assert(conditional.requires, `assistant matrix ${matrix.roleId} conditional ${conditional.capabilityId} missing requires`, errors)
}
}
assertUnique([...assistantOperationIds], 'assistant operation', errors)
for (const operation of assistantAccessPolicy.operations || []) {
assert(RULE_ID_RE.test(operation.id), `assistant operation id has invalid format: ${operation.id}`, errors)
assert(assistantCapabilityIds.has(operation.capabilityId), `assistant operation ${operation.id} references unknown capability: ${operation.capabilityId}`, errors)
for (const denyId of operation.hardDenies || []) {
assert(assistantHardDenyIds.has(denyId), `assistant operation ${operation.id} references unknown hard deny: ${denyId}`, errors)
}
assert(operation.summary, `assistant operation ${operation.id} missing summary`, errors)
}
assertUnique((assistantAccessPolicy.hardDenies || []).map((deny) => deny.id), 'assistant hard deny', errors)
for (const deny of assistantAccessPolicy.hardDenies || []) {
assert(RULE_ID_RE.test(deny.id), `assistant hard deny id has invalid format: ${deny.id}`, errors)
assertEntityRefs(deny.entityIds, entityById, `assistant hard deny ${deny.id}`, errors)
}
assertUnique([...assistantRiskLevelIds], 'assistant risk level', errors)
for (const riskLevel of assistantRiskPolicy.riskLevels || []) {
assert(ROLE_ID_RE.test(riskLevel.id), `assistant risk level id has invalid format: ${riskLevel.id}`, errors)
assert(riskLevel.summary, `assistant risk level ${riskLevel.id} missing summary`, errors)
}
assertUnique([...assistantConfirmationModeIds], 'assistant confirmation mode', errors)
for (const mode of assistantRiskPolicy.confirmationModes || []) {
assert(ROLE_ID_RE.test(mode.id), `assistant confirmation mode id has invalid format: ${mode.id}`, errors)
assert(mode.summary, `assistant confirmation mode ${mode.id} missing summary`, errors)
}
assertUnique([...assistantSelfActionPolicyIds], 'assistant self action policy', errors)
for (const policy of assistantRiskPolicy.selfActionPolicies || []) {
assert(ROLE_ID_RE.test(policy.id), `assistant self action policy id has invalid format: ${policy.id}`, errors)
assert(policy.summary, `assistant self action policy ${policy.id} missing summary`, errors)
}
assertUnique([...assistantActionHardDenyIds], 'assistant action hard deny', errors)
for (const deny of assistantRiskPolicy.hardDenies || []) {
assert(RULE_ID_RE.test(deny.id), `assistant action hard deny id has invalid format: ${deny.id}`, errors)
for (const actionId of deny.safeAlternatives || []) {
assert(assistantActionIds.has(actionId), `assistant action hard deny ${deny.id} references unknown safe alternative: ${actionId}`, errors)
}
}
assertUnique((assistantRiskPolicy.globalRules || []).map((rule) => rule.id), 'assistant risk global rule', errors)
for (const rule of assistantRiskPolicy.globalRules || []) {
assert(RULE_ID_RE.test(rule.id), `assistant risk global rule id has invalid format: ${rule.id}`, errors)
assert(['error', 'warning'].includes(rule.severity), `assistant risk global rule ${rule.id} invalid severity`, errors)
assert(rule.summary, `assistant risk global rule ${rule.id} missing summary`, errors)
}
assertUnique([...assistantAppIds], 'assistant app', errors)
for (const app of assistantActions.apps || []) {
assert(ROLE_ID_RE.test(app.id), `assistant app id has invalid format: ${app.id}`, errors)
assert(app.name, `assistant app ${app.id} missing name`, errors)
assert(app.adapterId, `assistant app ${app.id} missing adapterId`, errors)
assertEntityRefs(app.authorityEntityIds, entityById, `assistant app ${app.id}.authorityEntityIds`, errors)
}
assertUnique([...assistantAdapterStatuses], 'assistant adapter status', errors)
for (const status of assistantActions.adapterStatuses || []) {
assert(ROLE_ID_RE.test(status), `assistant adapter status has invalid format: ${status}`, errors)
}
assertUnique([...assistantActionIds], 'assistant action', errors)
for (const action of assistantActions.actions || []) {
assert(RULE_ID_RE.test(action.id), `assistant action id has invalid format: ${action.id}`, errors)
assert(assistantAppIds.has(action.app), `assistant action ${action.id} references unknown app: ${action.app}`, errors)
assert(action.domain, `assistant action ${action.id} missing domain`, errors)
assert(Array.isArray(action.intentAliases), `assistant action ${action.id} intentAliases must be array`, errors)
assertEntityRefs(action.entityIds, entityById, `assistant action ${action.id}.entityIds`, errors)
for (const capabilityId of action.capabilityIds || []) {
assert(assistantCapabilityIds.has(capabilityId), `assistant action ${action.id} references unknown capability: ${capabilityId}`, errors)
}
assert(assistantRiskLevelIds.has(action.riskLevel), `assistant action ${action.id} references unknown riskLevel: ${action.riskLevel}`, errors)
assert(assistantConfirmationModeIds.has(action.confirmationMode), `assistant action ${action.id} references unknown confirmationMode: ${action.confirmationMode}`, errors)
assert(assistantSelfActionPolicyIds.has(action.selfActionPolicy), `assistant action ${action.id} references unknown selfActionPolicy: ${action.selfActionPolicy}`, errors)
assert(action.adapterId, `assistant action ${action.id} missing adapterId`, errors)
assert(assistantAdapterStatuses.has(action.adapterStatus), `assistant action ${action.id} references unknown adapterStatus: ${action.adapterStatus}`, errors)
for (const denyId of action.hardDenyIds || []) {
assert(assistantActionHardDenyIds.has(denyId), `assistant action ${action.id} references unknown hard deny: ${denyId}`, errors)
}
for (const actionId of action.safeAlternativeActionIds || []) {
assert(assistantActionIds.has(actionId), `assistant action ${action.id} references unknown safe alternative action: ${actionId}`, errors)
}
if (action.riskLevel === 'destructive') {
assert(action.confirmationMode === 'forbidden', `assistant action ${action.id} destructive action must use confirmationMode=forbidden`, errors)
assert(action.adapterStatus === 'forbidden', `assistant action ${action.id} destructive action must use adapterStatus=forbidden`, errors)
}
if (action.confirmationMode === 'forbidden') {
assert(action.refusal, `assistant action ${action.id} forbidden action missing refusal`, errors)
}
if (action.riskLevel === 'privileged') {
assert(Array.isArray(action.requiredScopes) && action.requiredScopes.length > 0, `assistant action ${action.id} privileged action missing requiredScopes`, errors)
}
}
const contextStatuses = new Set(contextBindings.contextStatuses || [])
const bindingStatuses = new Set(contextBindings.bindingStatuses || [])
assertUnique(contextBindings.contexts.map((context) => context.id), 'context', errors)
for (const context of contextBindings.contexts) {
assert(RULE_ID_RE.test(context.id), `context id has invalid format: ${context.id}`, errors)
assert(entityById.has(context.entityId), `context ${context.id} references unknown entity: ${context.entityId}`, errors)
assert(contextStatuses.has(context.status), `context ${context.id} has invalid status: ${context.status}`, errors)
assert(context.surface, `context ${context.id} missing surface`, errors)
assert(context.sourceSystem, `context ${context.id} missing sourceSystem`, errors)
assertRefsObject(context.refs, `context ${context.id}`, errors)
if (context.parentContextId) {
assertContextRefs([context.parentContextId], contextById, `context ${context.id}`, errors)
}
}
assertUnique(contextBindings.bindingTypes.map((bindingType) => bindingType.id), 'binding type', errors)
for (const bindingType of contextBindings.bindingTypes) {
assert(RULE_ID_RE.test(bindingType.id), `binding type id has invalid format: ${bindingType.id}`, errors)
assertRelationRef(bindingType.relationId, relationById, `binding type ${bindingType.id}`, errors)
assertEntityRefs(bindingType.fromEntityIds, entityById, `binding type ${bindingType.id}.fromEntityIds`, errors)
assertEntityRefs(bindingType.toEntityIds, entityById, `binding type ${bindingType.id}.toEntityIds`, errors)
}
assertUnique(contextBindings.bindings.map((binding) => binding.id), 'binding', errors)
for (const binding of contextBindings.bindings) {
assert(RULE_ID_RE.test(binding.id), `binding id has invalid format: ${binding.id}`, errors)
assert(bindingStatuses.has(binding.status), `binding ${binding.id} has invalid status: ${binding.status}`, errors)
assert(bindingTypeById.has(binding.typeId), `binding ${binding.id} references unknown binding type: ${binding.typeId}`, errors)
assertContextRefs([binding.fromContextId, binding.toContextId], contextById, `binding ${binding.id}`, errors)
}
return {
ok: errors.length === 0,
errors,
warnings,
counts: {
entities: entities.entities.length,
relations: relations.relations.length,
aliases: aliases.aliases.length,
guardrails: guardrails.rules.length,
evidenceLedgers: evidence.ledgers.length,
resolverRules: resolverRules.rules.length,
assistantRoles: assistantAccessPolicy.roleVocabulary.length,
assistantCapabilities: assistantAccessPolicy.capabilities.length,
assistantOperations: assistantAccessPolicy.operations.length,
assistantActions: assistantActions.actions.length,
assistantApps: assistantActions.apps.length,
assistantRiskRules: assistantRiskPolicy.globalRules.length,
contexts: contextBindings.contexts.length,
bindingTypes: contextBindings.bindingTypes.length,
bindings: contextBindings.bindings.length,
},
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const result = await validateCatalog()
if (!result.ok) {
console.error(JSON.stringify(result, null, 2))
process.exit(1)
}
console.log(JSON.stringify(result, null, 2))
}