feat(ontology): add core service catalog
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user