From 24715414f143e2c58cf53084bd84cbea0922798f Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:48:47 +0800 Subject: [PATCH] Extend typed WebMCP workflows for External Alias and audit domains --- COMPATIBILITY.md | 27 ++-- README.md | 45 +++--- app/utils/ai-alias-service.js | 27 ++++ app/utils/ai-audit-reader.js | 130 +++++++++++++++ app/utils/ai-command-contract.js | 39 ++++- app/utils/ai-command-engine.js | 100 +++++++++++- app/utils/ai-external-service.js | 36 +++++ app/utils/ai-resource-registry.js | 15 +- app/utils/ai-resource-review-labels.js | 8 +- bridge/README.md | 15 +- bridge/core.mjs | 4 +- bridge/package-lock.json | 4 +- bridge/package.json | 2 +- bridge/server.mjs | 2 +- bridge/shared-modules.mjs | 3 + ...ass-replacement.node24-ignore-scripts.json | 4 +- docs/releases/web-console-1.6.201.md | 21 +++ docs/webmcp.md | 43 +++-- package-lock.json | 4 +- package.json | 2 +- scripts/check-modernization-blockers | 4 +- scripts/check-webmcp-engine-contract.mjs | 34 +++- scripts/ci | 3 + scripts/package-webmcp-bridge.py | 10 +- scripts/test-webmcp-alias-service.mjs | 122 ++++++++++++++ scripts/test-webmcp-audit-extension.mjs | 53 ++++++ scripts/test-webmcp-audit.mjs | 151 +++++++++++++++++ scripts/test-webmcp-external-service.mjs | 152 ++++++++++++++++++ scripts/test-webmcp-path-boundaries.mjs | 57 ++++++- 29 files changed, 1028 insertions(+), 89 deletions(-) create mode 100644 app/utils/ai-alias-service.js create mode 100644 app/utils/ai-audit-reader.js create mode 100644 app/utils/ai-external-service.js create mode 100644 docs/releases/web-console-1.6.201.md create mode 100644 scripts/test-webmcp-alias-service.mjs create mode 100644 scripts/test-webmcp-audit-extension.mjs create mode 100644 scripts/test-webmcp-audit.mjs create mode 100644 scripts/test-webmcp-external-service.mjs diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index adaffa87a4..95bbb8e255 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,20 +1,17 @@ # Compatibility Contract -## Web Console 1.6.200 / bridge 0.2.2 - -The [published components](docs/releases/web-console-1.6.200.md) retain normal -UI/session/Full-access behavior and require a matching Engine with private -Catalog ingress protection, live actor/Key scope, CAS and per-child graph -validation. Catalog launch cannot safely target Engine 0.183.358. Archive/restore -reuses the native stopped-only owner and never starts restored workloads. Fixed -background authorization-denied results remain failed, warn about possible -partial changes and cannot be blindly replayed. - -The original producer passed required full CI and CodeQL; the published assets -passed anonymous byte verification. The published tag and producer share the -same source tree. Combined Server, Catalog/Archive browser, role and weak-model -runtime acceptance remain **PENDING**; partial source coverage is not a whole -business-group PASS. +## Web Console 1.6.201 / bridge 0.2.3 candidate + +External Service and DNS Alias workflows need Engine 0.183.360. Alias creation +is empty/stopped; binding selected local service references needs a separate +human-approved plan. Unsupported link types and cross-source cleanup remain +explicitly unavailable. Existing Catalog/Archive/session behavior is preserved. +Typed audit queries need both the matching Engine and installed broker owner. +An older Cookie-only adapter returns an actionable denial; the bridge must not +forge a Cookie or switch credentials. Raw audit payloads stay human-only. +Normal Full/legacy permissions remain unchanged. Required CI, clean-install and +combined runtime acceptance are pending. See the +[candidate release note](docs/releases/web-console-1.6.201.md). ## Web Console 1.6.195 diff --git a/README.md b/README.md index 9f60b8e771..ac52b2bb71 100644 --- a/README.md +++ b/README.md @@ -10,31 +10,26 @@ history, authorship, licenses, and dependency notices. ## Current component -Web Console **1.6.200** and stdio bridge **0.2.2** are [published](https://github.com/PastureStack/web-console/releases/tag/1.6.200). -They retain the twelve typed tools and add bounded Catalog Compose launch and -stopped-resource Archive/restore workflows. Private Catalog answers require -normal human input/review; restoration does not start workloads. Other Catalog -administration, advanced forms and remaining frontend workflows are pending. - -The [release note](docs/releases/web-console-1.6.200.md) records the original -qualified producer, immutable archive/SBOM hashes and published tag. Full CI -passed 1,121 Ember tests with zero failures; required CodeQL and anonymous hash -verification of all 13 release assets passed. The standalone ZIP passed a clean -install and 48 focused adapter/SDK cases. - -Combined Server acceptance and the designated Catalog/Archive browser, role and -weak-model workflows remain **PENDING**. The inventory records partial typed -source coverage for 54 of 243 business groups; this is not whole-group acceptance -or complete frontend coverage. Earlier released artifacts remain unchanged. - -The release producer uses Node.js `24.22.1` / npm `12.0.2` in a digest-pinned -container and produces an immutable static archive with separate runtime and -build CycloneDX SBOMs. [SBOM coverage](docs/security/sbom.md) remains explicitly -incomplete for inherited vendor metadata; hosted-runner Ruby is tracked -separately. CI, Server assembly and deployment require their own evidence. - -Use the [Server installation guide](https://github.com/PastureStack/server#quick-start) -for deployment. Historical changes remain in [release notes](docs/releases). +Current source is Web Console **1.6.201** / bridge **0.2.3**, not yet published. +The latest published client remains +[1.6.200 / 0.2.2](https://github.com/PastureStack/web-console/releases/tag/1.6.200). + +The candidate retains twelve tools and extends typed workflows for External +Services, DNS Aliases and audit metadata. Alias creation and selecting targets +are separate human-reviewed changes. Queries use opaque references; no raw +resource IDs, private audit payloads or credentials are exposed to tools. +Matching Engine 0.183.360 and broker audit-query components are required. + +Normal UI, session, Full-access, existing Catalog/Archive and human-confirmation +protections remain. Required CI/CodeQL, immutable artifact/clean-install proof, +combined Server and designated browser/role/weak-model acceptance are pending. +The [candidate release note](docs/releases/web-console-1.6.201.md) records the +remaining boundaries. This candidate does not complete all 243 business groups. + +The release producer uses Node.js `24.22.1` / npm `12.0.2` and separate runtime / +build CycloneDX SBOMs. Dependencies remain unchanged. Use the +[Server installation guide](https://github.com/PastureStack/server#quick-start) +for deployment. Historical published evidence remains in [release notes](docs/releases). ## Features and operator guides diff --git a/app/utils/ai-alias-service.js b/app/utils/ai-alias-service.js new file mode 100644 index 0000000000..71bf226f2e --- /dev/null +++ b/app/utils/ai-alias-service.js @@ -0,0 +1,27 @@ +import {CommandError} from './ai-command-contract'; + +export const ALIAS_WORKFLOW = Object.freeze(['create', 'update_targets', 'optional_start']); +export const ALIAS_CREATE_NEXT_STEP = 'Only an empty stopped Alias is being created; no targets are bound yet. After this create operation is verified, find the new dnsService by its exact name and Stack, then preview update_targets with the selected same-session serviceRefs for a separate human approval. Start is another optional approved plan. Do not report the complete Alias workflow as finished after create.'; + +export function aliasTargetIds(references, resolve) { + if ( !Array.isArray(references) || references.length > 16 ) { throw new CommandError('INVALID_INPUT', 'parameters.serviceRefs'); } + const ids = []; + for ( const reference of references ) { + const target = resolve(reference); + if ( target.scope !== 'project' || !['service', 'dnsService', 'externalService', 'loadBalancerService'].includes(target.resourceType) || ids.includes(target.id) ) { + throw new CommandError('INVALID_INPUT', 'parameters.serviceRefs'); + } + ids.push(target.id); + } + return ids; +} + +export function safeAliasTargetSummary(value) { + if ( value === null ) { return; } + let names; + try { names = JSON.parse(value); } catch (error) { throw new CommandError('INVALID_RESPONSE'); } + if ( !Array.isArray(names) || names.length > 16 || names.some((name) => typeof name !== 'string' || name.length > 768) ) { + throw new CommandError('INVALID_RESPONSE'); + } + return names; +} diff --git a/app/utils/ai-audit-reader.js b/app/utils/ai-audit-reader.js new file mode 100644 index 0000000000..c2077924ea --- /dev/null +++ b/app/utils/ai-audit-reader.js @@ -0,0 +1,130 @@ +import { CommandError } from './ai-command-contract'; + +const PATH = 'pasturestack/ai-audit-queries'; +const TOKEN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/; +const PRESENCE = ['present', 'not_projected']; +const ID_FIELDS = ['id', 'accountId', 'authenticatedAsAccountId', 'keyId', 'resourceId', 'targetId', 'processId']; +export const AUDIT_FIELDS = Object.freeze(['created', 'eventType', 'resourceType', 'authType', 'decision', 'outcome', + 'responseCode', 'httpStatus', 'requestId', 'targetType', 'operation', 'phase', 'policyRevision', 'preview', + 'processName', 'failureCode', 'interactionChannel', 'payloadsIncluded', 'requestPayloadPresence', 'responsePayloadPresence']); +const invalid = (field = 'auditFilters') => { throw new CommandError('INVALID_INPUT', field); }; +const badResponse = () => { throw new CommandError('INVALID_RESPONSE'); }; +const object = value => value && typeof value === 'object' && !Array.isArray(value); +const token = value => typeof value === 'string' && TOKEN.test(value); +const instant = value => typeof value === 'string' && value.length <= 64 && + /^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value)); + +export function auditFilterSignature(filters) { + return JSON.stringify(Object.keys(filters).sort().map(key => [key, filters[key]])); +} + +export function auditSafeFields(row) { + const result = {}; + for (const field of AUDIT_FIELDS) if (Object.hasOwn(row, field)) result[field] = row[field]; + return result; +} + +export function createAuditReader({request, context, resolveResource, transportKind}) { + async function read(data, captured, signal) { + context.assertCurrent(captured, signal); + if (!['browser', 'stdio'].includes(transportKind)) throw new CommandError('UNAVAILABLE', 'auditFilters.transport'); + let response; + try { response = await request({url: PATH, method: 'POST', data, signal}); } + catch (error) { + context.assertCurrent(captured, signal); + const status = Number(error && error.status), code = error && error.body && error.body.code; + if (status === 503 && ['AuditQueryUnavailable', 'AuditQueryAuditUnavailable'].includes(code)) { + throw new CommandError('UNAVAILABLE', code === 'AuditQueryAuditUnavailable' ? 'auditFilters.audit' : 'auditFilters.service'); + } + if (status === 422 && ['AuditQueryInputInvalid', 'AuditQueryNarrowQuery'].includes(code)) { + invalid(code === 'AuditQueryNarrowQuery' ? 'auditFilters.timeRange' : 'auditFilters'); + } + if (status === 502 && ['invalid_audit_response', 'invalid_key_authority', 'invalid_key_audit_scope'].includes(code)) badResponse(); + if ([500, 502, 503].includes(status) && ['audit_upstream_unavailable', 'key_audit_unavailable', 'audit_query_failed'].includes(code)) { + throw new CommandError('UNAVAILABLE', 'auditFilters.service'); + } + if ([400, 422].includes(Number(error && error.status)) && + ['result_set_too_large', 'time_range_too_large', 'incomplete_time_range', 'invalid_time_range'].includes(error.body && error.body.code)) { + invalid('auditFilters.timeRange'); + } + throw error; + } + context.assertCurrent(captured, signal); + const body = response && response.body; + if (!object(body) || body.resourceType !== 'auditLog' || body.payloadPolicy !== 'HUMAN_ONLY_NATIVE_AUDIT_UI' || + !Array.isArray(body.data) || body.data.length > 50 || !Number.isInteger(body.total) || body.total < 0 || body.total > 20000 || + !object(body.range) || Object.keys(body.range).some(key => !['from', 'to'].includes(key)) || + !instant(body.range.from) || !instant(body.range.to) || + Date.parse(body.range.from) >= Date.parse(body.range.to) || + Date.parse(body.range.to) - Date.parse(body.range.from) > 366 * 86400000 || + data.lastMinutes && Date.parse(body.range.to) - Date.parse(body.range.from) !== data.lastMinutes * 60000 || + data.from && Date.parse(data.from) !== Date.parse(body.range.from) || + data.to && Date.parse(data.to) !== Date.parse(body.range.to)) badResponse(); + const allowed = [...ID_FIELDS, ...AUDIT_FIELDS, 'type']; + const rows = body.data.map(row => { + if (!object(row) || row.type !== 'auditLog' || !token(row.id) || row.payloadsIncluded !== false || + !PRESENCE.includes(row.requestPayloadPresence) || !PRESENCE.includes(row.responsePayloadPresence) || + Object.keys(row).some(key => !allowed.includes(key)) || + data.environmentId && row.accountId !== data.environmentId || data.keyId && row.keyId !== data.keyId) badResponse(); + for (const field of [...ID_FIELDS, ...AUDIT_FIELDS]) { + const value = row[field]; + if (value === undefined) continue; + if (['preview', 'payloadsIncluded'].includes(field)) { + if (typeof value !== 'boolean') badResponse(); + } else if (['httpStatus', 'policyRevision'].includes(field)) { + if (!Number.isSafeInteger(value) || value < 0) badResponse(); + } else if (field === 'created') { + if (!instant(value)) badResponse(); + } else if (!token(value)) badResponse(); + } + return {...row}; + }); + if (new Set(rows.map(row => row.id)).size !== rows.length) badResponse(); + const offset = data.offset || 0; + const end = Math.min(offset, body.total) + rows.length; + if (end > body.total || (end < body.total ? body.nextOffset !== end || end <= offset : body.nextOffset !== null)) badResponse(); + return {rows, total: body.total, nextOffset: body.nextOffset, range: {...body.range}}; + } + + function query(filters, captured) { + const data = {environmentId: captured.projectId}; + if (filters.environmentScope === 'all-visible') delete data.environmentId; + if (filters.environmentRef) { + if (filters.environmentScope === 'all-visible') invalid('auditFilters.environmentRef'); + const target = resolveResource(filters.environmentRef, captured, 'auditFilters.environmentRef'); + if (target.resourceType !== 'project' || target.scope !== 'global') invalid('auditFilters.environmentRef'); + data.environmentId = target.id; + } + if (filters.keyRef) { + const target = resolveResource(filters.keyRef, captured, 'auditFilters.keyRef'); + if (!['apiKey', 'apiKeyRestricted'].includes(target.resourceType) || target.scope !== 'global') invalid('auditFilters.keyRef'); + data.keyId = target.id; + } + if (filters.lastMinutes !== undefined && (filters.from !== undefined || filters.to !== undefined)) invalid('auditFilters.timeRange'); + if (filters.from !== undefined || filters.to !== undefined) { + if (!instant(filters.from) || !instant(filters.to) || Date.parse(filters.from) >= Date.parse(filters.to) || + Date.parse(filters.to) - Date.parse(filters.from) > 366 * 86400000) invalid('auditFilters.timeRange'); + } + for (const field of ['from', 'to', 'lastMinutes', 'decision', 'outcome', 'operation', 'targetType', 'requestId', 'httpStatus', 'limit']) { + if (filters[field] !== undefined) data[field] = filters[field]; + } + return data; + } + return Object.freeze({ + async find(filters, cursor, captured, signal) { + const selected = query(filters, captured); + const signature = auditFilterSignature(filters); + if (cursor && (cursor.auditFilterSignature !== signature || !object(cursor.auditQuery))) invalid('cursor'); + const data = cursor ? {...cursor.auditQuery, offset: cursor.offset} : selected; + const result = await read(data, captured, signal); + const frozen = {...selected, from: result.range.from, to: result.range.to}; + delete frozen.lastMinutes; + return {...result, query: frozen, signature}; + }, + async get(target, captured, signal) { + const result = await read({...target.auditQuery, eventId: target.id}, captured, signal); + if (result.rows.length !== 1 || result.rows[0].id !== target.id || result.nextOffset !== null) badResponse(); + return result.rows[0]; + }, + }); +} diff --git a/app/utils/ai-command-contract.js b/app/utils/ai-command-contract.js index 2afad0b0e7..e6ee2ed787 100644 --- a/app/utils/ai-command-contract.js +++ b/app/utils/ai-command-contract.js @@ -11,22 +11,41 @@ const commandArray = {type: 'array', maxItems: 64, 'x-maxTotalLength': 16384, const catalogAnswers = {type: 'array', maxItems: 100, items: schema({questionRef: string('questionRef from the selected version launchOptions.'), value: {type: ['string', 'number', 'boolean'], minLength: 0, maxLength: 4096, minimum: -Number.MAX_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER}, resourceRef: string('An authorized resourceRef, or a same-session serviceRef for service questions. Never send a raw resource ID.')}, ['questionRef']), description: 'Typed answers only. Each questionRef accepts exactly one value or resourceRef. For an optional reference, value "" explicitly selects no resource. Human-only inputs must be completed in the console; never send a password or secret value.'}; +const auditFilters = schema({ + keyRef: string('Optional API Key resourceRef returned by find_resources; never supply a raw credential ID.'), + environmentRef: string('Optional visible project resourceRef; current environment is used by default.'), + environmentScope: {...string('Use current by default. all-visible explicitly requests only environments allowed by the native audit owner.'), enum: ['current', 'all-visible']}, + from: string('Range start, inclusive, RFC3339 with timezone; supply together with to.', 64), + to: string('Range end, exclusive, RFC3339 with timezone; at most 366 days after from.', 64), + lastMinutes: {type: 'integer', minimum: 1, maximum: 527040, description: 'Relative range. Do not combine with from/to. Omitted range means the last 24 hours.'}, + decision: {...string('Exact authorization decision.'), enum: ['ALLOW', 'DENY', 'NOT_EVALUATED']}, + outcome: {...string('Exact outcome, such as SUCCEEDED, FAILED or UNKNOWN.', 160), pattern: '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$'}, + operation: {...string('Exact native audit operation.'), enum: ['read', 'create', 'update', 'delete', 'upgrade', 'execute']}, + targetType: {...string('Optional exact target type, such as service or stack.', 160), pattern: '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$'}, + requestId: {...string('Optional exact correlation request ID from safe metadata.', 160), pattern: '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$'}, + httpStatus: {type: 'integer', minimum: 100, maximum: 599, description: 'Exact HTTP response status.'}, + limit: {type: 'integer', minimum: 1, maximum: 50, description: 'Page size, maximum 50. Reuse nextCursor with unchanged filters.'}, +}); export const COMMANDS = Object.freeze([ {name: 'list_capabilities', description: 'List the closed resource families, their implementation coverage and current read availability. Pending adapters are not implemented. Does not change environment or expose credentials.', inputSchema: schema({})}, {name: 'find_resources', description: 'Find visible resource metadata in a named family. resourceType defaults to the first type listed by list_capabilities. Return all same-name candidates; never select the first automatically. In catalog, find catalogTemplate by name first, then list catalogTemplateVersion with its resourceRef as parentRef and no query. Version metadata does not establish compatibility. Parent and cursor references must be reused unchanged.', inputSchema: schema({family: {...string('Family from list_capabilities.'), enum: RESOURCE_FAMILIES.map((item) => item.name)}, resourceType: {...string('Optional type within that family, from list_capabilities.'), enum: RESOURCE_TYPES}, query: string('Optional resource name words.'), parentRef: string('Optional resourceRef of the parent type listed by list_capabilities.'), - cursor: string('The previous nextCursor, unchanged with the same family, type, query and parent.')}, ['family'])}, + cursor: string('The previous nextCursor, unchanged with the same family, type, query, parent and audit filters.'), + auditFilters: {...auditFilters, description: 'Only family audit / auditLog. Explicitly query the native audit broker with these typed filters; do not combine with name query or parentRef. Available through the native browser session and the dedicated API Key bridge when matching Engine and broker audit-query components are installed. Current actor permission, Key scope and policy revision still apply. Request/response payloads remain human-only.'}}, ['family'])}, {name: 'get_resource', description: 'Read safe metadata from a resourceRef returned by find_resources. Description is preserved through 1024 UTF-16 units. Check truncatedFields and never construct an append or replacement from truncated text. Secrets, API keys, tokens, environment variables and raw configuration are never returned.', inputSchema: schema({resourceRef: string('Opaque resourceRef from find_resources, unchanged.')}, ['resourceRef'])}, - {name: 'preview_resource_change', write: true, description: 'Preview a typed stack, service or container change. A human must approve the exact server plan before apply. create needs name (and image for service/container); service create also needs parentRef of its stack. Plain create makes stopped workloads; start needs a separate plan. Catalog stack create_from_catalog needs catalogVersionRef and typed answers; startOnCreate explicitly controls service startup. Stack/service archive requires completely stopped workloads and empty parameters; it never stops or deletes them. restore clears only the target archive flag, does not start it or change parent/child flags. Other operations require targetRef. Credentials, raw IDs and arbitrary configuration are not accepted.', + {name: 'preview_resource_change', write: true, description: 'Preview a typed stack, service, externalService, dnsService (Alias) or container change. A human must approve the exact server plan before apply. create needs name (and image for service/container); service/externalService/dnsService create also needs parentRef of its stack. Alias create makes an empty stopped Alias without targets; after verification use update_targets with serviceRefs for a separately approved target binding. External Service create requires exactly one hostname or nonempty externalIpAddresses, without an image. Plain create makes stopped workloads; start needs a separate plan. Catalog stack create_from_catalog needs catalogVersionRef and typed answers; startOnCreate explicitly controls service startup. Stack/service archive requires completely stopped workloads and empty parameters; it never stops or deletes them. restore clears only the target archive flag, does not start it or change parent/child flags. Other operations require targetRef. Credentials, raw IDs and arbitrary configuration are not accepted.', inputSchema: schema({resourceType: {...string('Resource type from list_capabilities.'), enum: Object.keys(RESOURCE_CHANGES)}, operation: {...string('A supported operation from list_capabilities.'), enum: CHANGE_OPERATIONS}, - targetRef: string('Existing resourceRef for operations on an existing resource. Do not provide for create or create_from_catalog.'), parentRef: string('Stack resourceRef, required only for service create.'), + targetRef: string('Existing resourceRef for operations on an existing resource. Do not provide for create or create_from_catalog.'), parentRef: string('Stack resourceRef, required for service, externalService or dnsService create.'), catalogVersionRef: string('Selected catalogTemplateVersion resourceRef, required only for stack create_from_catalog. Call get_resource on it first for safe questions.'), parameters: schema({name: string('Exact resource name.', 255), description: {type: ['string', 'null'], minLength: 0, maxLength: 1024, description: 'Full description. Empty string and null are distinct.'}, image: string('Exact image requested by the user. Required for service/container create.', 512), scale: {type: 'integer', minimum: 0, maximum: 100, description: 'Service scale, 0 through 100.'}, + hostname: string('DNS hostname only, without a scheme, userinfo, port, path, query or fragment.', 253), + externalIpAddresses: {type: 'array', minItems: 1, maxItems: 16, uniqueItems: true, items: string('Numeric IPv4 or IPv6 address, no port or scope.', 45)}, + serviceRefs: {type: 'array', maxItems: 16, uniqueItems: true, items: string('Same-session service resourceRef or legacy serviceRef. Never a raw ID.'), description: 'Alias update_targets only. Exact selected services; [] explicitly clears all simple targets. Creation is a separate empty stopped Alias stage.'}, command: commandArray, entryPoint: commandArray, startOnCreate: {type: 'boolean', description: 'Catalog launch only. Defaults to true and is shown in human review.'}, answers: catalogAnswers})}, ['resourceType', 'operation'])}, {name: 'apply_resource_change', write: true, description: 'Apply only an existing human-approved resource-change planRef. accepted/running/unknown are not completion. Query get_operation with the returned operationRef; never blindly resubmit.', inputSchema: schema({planRef: string('planRef from preview_resource_change, unchanged.')}, ['planRef'])}, @@ -167,6 +186,20 @@ export function publicError(error) { result.nextStep = 'Call find_resources again with a narrower query, such as the exact resource name, in the same family. Do not guess IDs or switch environments.'; } if ( error instanceof CommandError && error.field ) { result.field = error.field; } + if (error instanceof CommandError && error.field === 'auditFilters.transport') { + result.nextStep = 'Use the current native browser session or the dedicated stdio bridge. The server must authorize audit reads and any target Key metadata under current permissions. Do not forge identity headers or treat a rejected query as complete.'; + } else if (error instanceof CommandError && error.field === 'auditFilters.service') { + result.nextStep = 'The native audit owner is temporarily unavailable. This read query did not establish any result. Retry a bounded read after service recovery with the same Key/environment filters; do not change permissions or infer that zero events exist.'; + } else if (error instanceof CommandError && error.field === 'auditFilters.audit') { + result.nextStep = 'The server could not durably record the query audit evidence. No successful query result was admitted. Ask the operator to restore the existing audit store; preserve the same filters and permissions.'; + } else if (error instanceof CommandError && error.field === 'auditFilters.timeRange') { + result.nextStep = 'Use lastMinutes or a complete from/to timezone range within 366 days. Narrow the time range and visible environment if the native 20,000-row scan bound is reached; keep the intended Key and permission filters.'; + } else if (error instanceof CommandError && error.field && error.field.startsWith('auditFilters')) { + result.nextStep = 'Call find_resources with family audit, resourceType auditLog and auditFilters. Reuse authorized API Key/project resourceRefs, choose a bounded time range and keep filters unchanged with nextCursor. Do not send raw IDs, URLs or payload fields.'; + } + if (code === 'FORBIDDEN' && serverCode === 'ai_audit_cookie_required') { + result.nextStep = 'The server is using the earlier Cookie-only audit adapter. The dedicated bridge needs the matching Engine and broker audit-query components. Do not forge a Cookie, switch credentials or treat this denial as a successful query.'; + } return result; } diff --git a/app/utils/ai-command-engine.js b/app/utils/ai-command-engine.js index 2715784ed6..306988ff74 100644 --- a/app/utils/ai-command-engine.js +++ b/app/utils/ai-command-engine.js @@ -1,6 +1,9 @@ +import {validateExternalServiceParameters, isExternalHostname, isExternalAddress} from './ai-external-service'; +import {aliasTargetIds, safeAliasTargetSummary, ALIAS_WORKFLOW, ALIAS_CREATE_NEXT_STEP} from './ai-alias-service'; import { COMMANDS, COMMAND_VERSION, MAX_PAGE_SIZE, CommandError, publicError, safeText, validateInput } from './ai-command-contract'; import { RESOURCE_FAMILIES, RESOURCE_CHANGES, RESOURCE_CHANGE_FIELDS, resourceFamily, resourceDescriptor, safeResourceFields, truncatedResourceFields } from './ai-resource-registry'; import { createCatalogReader } from './ai-catalog-reader'; +import { createAuditReader, AUDIT_FIELDS, auditSafeFields } from './ai-audit-reader'; import { createCatalogLaunchAdapter, catalogLaunchPlanSummary, CATALOG_LAUNCH_COLLECTION } from './ai-catalog-launch'; const OPERATION_STATUSES = ['accepted', 'running', 'succeeded', 'failed', 'unknown', 'cancelled', 'needs_user']; @@ -36,6 +39,8 @@ export class CommandEngine { constructor({context, request, ensureSession, audit = async () => { throw new CommandError('UNAVAILABLE'); }, wait = waitForOperationPoll, onPlan = () => {}, onOperation = () => {}, origin = 'https://localhost', apiEndpoint = '/v2-beta', transportKind = 'browser'}) { Object.assign(this, {context, request, ensureSession, audit, wait, onPlan, onOperation, origin, apiEndpoint, transportKind}); this.catalogReader = createCatalogReader({request, context}); + this.auditReader = createAuditReader({request, context, transportKind, + resolveResource: (reference, captured, field) => this.resolveResourceReference(reference, null, captured, field)}); this.catalogLaunch = createCatalogLaunchAdapter({request, context, resolveResource: (reference, type, captured) => this.resolveResourceReference(reference, type, captured, 'parameters.answers')}); } @@ -100,6 +105,7 @@ export class CommandEngine { 'parameters.name': 'Provide the requested name in parameters.name, from 1 to 255 UTF-16 units.', 'parameters.scale': 'Provide an integer from 0 through 100 in parameters.scale.', 'parameters.description': 'Provide the complete description as a string of at most 1024 UTF-16 units, or null. An empty string clears it.', + 'parameters.serviceRefs': 'Find each exact service by name and Stack with find_resources. Select its same-session resourceRef; never choose the first same-name candidate, guess a raw ID or reuse a stale reference. Supply at most 16 distinct serviceRefs; [] explicitly clears the simple targets.', operation: 'Use list_capabilities and choose an implemented operation for this resource type.', }; result.nextStep = input && ['archive', 'restore'].includes(input.operation) && result.field && result.field.startsWith('parameters') ? @@ -205,12 +211,19 @@ export class CommandEngine { const supported = this.supportsResource(descriptor), needsCheck = supported && (this.usesResourceView(descriptor) || descriptor.readTransport === 'catalog'); return {resourceType: descriptor.type, scope: descriptor.scope, coverageStatus: supported && !descriptor.limitations.length ? 'implemented' : 'pending-adapter', metadataLimitations: [...descriptor.limitations], + typedAuditQuery: descriptor.type === 'auditLog' ? { + availability: 'implemented', + transport: 'native-cookie-or-dedicated-api-key', authorization: 'requires-current-native-owner-and-key-read-scope', + payloads: 'human-only-withheld', safeFields: [...AUDIT_FIELDS], + input: 'find_resources.auditFilters', maximumPageRows: 50, maximumScanRows: 20000, + } : null, canFind: needsCheck ? null : supported && !!definition && Array.isArray(definition.collectionMethods) && definition.collectionMethods.includes('GET'), canGet: needsCheck ? null : supported && !!definition && Array.isArray(definition.resourceMethods) && definition.resourceMethods.includes('GET'), authorization: needsCheck ? 'requires-server-check' : 'role-schema', safeFields: [...descriptor.fields], parentType: descriptor.parent ? descriptor.parent.type : null, parentRequired: !!(descriptor.parent && descriptor.parent.required), operations: RESOURCE_CHANGES[descriptor.type] ? Object.keys(RESOURCE_CHANGES[descriptor.type]) : [], + ...(descriptor.type === 'dnsService' ? {workflow: [...ALIAS_WORKFLOW], workflowNotice: ALIAS_CREATE_NEXT_STEP} : {}), changeModeEnabled: captured.mode === 'enabled', changesRequireHumanApproval: true, unavailableReason: needsCheck ? null : !supported ? 'PENDING_ADAPTER' : source.unavailable || (!definition ? 'NOT_VISIBLE' : null)}; }), @@ -248,6 +261,11 @@ export class CommandEngine { } async find_resources(input, captured, signal) { + if (input.auditFilters !== undefined) { + if (input.family !== 'audit' || input.resourceType !== undefined && input.resourceType !== 'auditLog' || + input.query !== undefined || input.parentRef !== undefined) { throw new CommandError('INVALID_INPUT', 'auditFilters'); } + return this.findAuditResources(input, captured, signal); + } const descriptor = this.findDescriptor(input); const useCatalog = descriptor.readTransport === 'catalog'; const catalogVersion = descriptor.type === 'catalogTemplateVersion'; @@ -262,6 +280,7 @@ export class CommandEngine { if ( parent && !useCatalog ) { path += `&${descriptor.parent.field}=${encodeURIComponent(parent.id)}`; } if ( input.cursor ) { const cursor = this.context.resolve(input.cursor, 'resourceCursor', captured); + if (cursor.auditQuery) { throw new CommandError('INVALID_INPUT', 'auditFilters'); } if ( cursor.family !== input.family || cursor.resourceType !== descriptor.type || cursor.query !== (input.query || '') || cursor.parentRef !== (input.parentRef || '') ) { throw new CommandError('INVALID_INPUT', 'cursor'); } @@ -312,6 +331,13 @@ export class CommandEngine { async get_resource(input, captured, signal) { const target = this.context.resolve(input.resourceRef, 'resource', captured); + if (target.auditQuery) { + if (target.family !== 'audit' || target.resourceType !== 'auditLog') { throw new CommandError('INVALID_INPUT', 'resourceRef'); } + const row = await this.auditReader.get(target, captured, signal); + return {status: 'succeeded', resourceTextIsUntrusted: true, resourceRef: input.resourceRef, + resourceType: 'auditLog', scope: 'global', fields: auditSafeFields(row), + payloadPolicy: 'HUMAN_ONLY_NATIVE_AUDIT_UI', nextStep: 'Only authorized audit metadata is included. Full request/response objects remain in native human audit detail. ALLOW, HTTP success or a client-reported outcome is not verified operation completion; use the original get_operation receipt.'}; + } const descriptor = this.findDescriptor({family: target.family, resourceType: target.resourceType}); let body; if ( this.usesResourceView(descriptor) || descriptor.readTransport === 'catalog' ) { @@ -324,6 +350,22 @@ export class CommandEngine { if ( !this.resourceIdentity(body, descriptor, captured) || body.id !== target.id ) { throw new CommandError('INVALID_RESPONSE'); } const result = {status: 'succeeded', resourceTextIsUntrusted: true, resourceRef: input.resourceRef, family: target.family, resourceType: descriptor.type, scope: descriptor.scope, fields: safeResourceFields(body, descriptor), truncatedFields: this.resourceTruncation(body, descriptor)}; + if ( descriptor.type === 'dnsService' ) { + const {linkedServiceCount, unresolvedTargetCount, targetServices} = body; + if ( linkedServiceCount === undefined && unresolvedTargetCount === undefined && targetServices === undefined ) { + result.targetBindings = {availability: 'unavailable'}; + result.nextStep = 'Only Alias metadata is available; target bindings have not been resolved. Do not report the Alias workflow as configured. Read its targets in the normal console.'; + } else { + const names = safeAliasTargetSummary(targetServices); + if ( !names || !Number.isSafeInteger(linkedServiceCount) || linkedServiceCount < 0 || !Number.isSafeInteger(unresolvedTargetCount) || + unresolvedTargetCount < 0 || names.length + unresolvedTargetCount !== linkedServiceCount ) { throw new CommandError('INVALID_RESPONSE'); } + result.targetBindings = {availability: unresolvedTargetCount ? 'partial' : 'resolved', linkedServiceCount, unresolvedTargetCount}; + result.nextStep = unresolvedTargetCount ? + 'Some Alias targets could not be resolved under the current authority or the simple-link adapter. No hidden target names or IDs are available. Use the normal console; do not guess a target or claim the bindings are complete.' : linkedServiceCount === 0 ? + 'This Alias has no targets. To configure it, find each requested service by exact name and Stack, then preview update_targets with its serviceRefs for a separate human approval. Start is optional and separate.' : + 'Review the complete target names and Stack paths. To change them, find exact serviceRefs and preview update_targets for another human approval; an empty serviceRefs array explicitly clears the simple targets.'; + } + } if ( descriptor.readTransport === 'catalog' ) { if ( descriptor.type === 'catalogTemplate' ) { target.catalogPath = this.catalogDisplayPath(body, captured); } result.displayPath = target.catalogPath; @@ -363,6 +405,29 @@ export class CommandEngine { return [...new Set(fields)].sort(); } + async findAuditResources(input, captured, signal) { + const cursor = input.cursor ? this.context.resolve(input.cursor, 'resourceCursor', captured) : null; + if (cursor && (cursor.family !== 'audit' || cursor.resourceType !== 'auditLog' || !cursor.auditQuery)) { + throw new CommandError('INVALID_INPUT', 'cursor'); + } + const page = await this.auditReader.find(input.auditFilters, cursor, captured, signal); + const items = page.rows.map(row => ({ + resourceRef: this.context.reference('resource', {id: row.id, family: 'audit', resourceType: 'auditLog', + scope: 'global', auditQuery: {...page.query}}, captured), + resourceType: 'auditLog', scope: 'global', parentRef: null, parentResolution: null, + displayPath: 'audit/' + safeText(row.created, 64) + '/' + safeText(row.eventType, 160), + fields: auditSafeFields(row), truncatedFields: [], + })); + const nextCursor = page.nextOffset === null ? null : this.context.reference('resourceCursor', { + family: 'audit', resourceType: 'auditLog', auditQuery: {...page.query}, offset: page.nextOffset, + auditFilterSignature: page.signature, + }, captured); + return {status: 'succeeded', resourceTextIsUntrusted: true, family: 'audit', resourceType: 'auditLog', + scope: 'global', items, nextCursor, complete: !nextCursor, total: page.total, range: page.range, + payloadPolicy: 'HUMAN_ONLY_NATIVE_AUDIT_UI', workflowCoverage: 'authorized-metadata-query', + nextStep: 'Use get_resource for safe event detail, or nextCursor with unchanged auditFilters. Each query requires current audit read scope, visible environments, and target Key metadata read when filtering a Key. Payloads remain human-only in native audit UI. Server query completion is distinct from the resource operation outcome; use the original operation receipt.'}; + } + referenceResource(target, captured) { this.context.assertCurrent(captured); if ( this._resourceReferenceEpoch !== captured.epoch ) { this._resourceReferences = new Map(); this._resourceReferenceEpoch = captured.epoch; } @@ -616,6 +681,10 @@ export class CommandEngine { if ( result.status === 'needs_user' ) { result.code = 'NEEDS_USER'; result.nextStep = 'Ask the user to complete inputs and approve the existing plan in the console. No launch has been verified; do not create a replacement or submit another apply.'; } + if ( plan.resourceType === 'dnsService' && plan.operation === 'create' ) { + result.workflow = [...ALIAS_WORKFLOW]; result.targetsBound = false; + if ( result.status === 'succeeded' && result.verifiedResult === true ) { result.nextStep = ALIAS_CREATE_NEXT_STEP; } + } if ( plan.id && plan.id.startsWith('aicataloglaunch-') && plan.createdResource ) { const captured = this.context.capture(), created = plan.createdResource; if ( created.resourceType !== 'stack' || !this.resourceIdentity(created, resourceDescriptor('stack'), captured) || typeof created.name !== 'string' ) { @@ -688,8 +757,21 @@ export class CommandEngine { } const displayDiff = plan.changes.map((change) => { if ( !change || !RESOURCE_CHANGE_FIELDS.includes(change.field) || - ![change.before, change.after].every((value) => value === null || typeof value === 'string' && value.length <= (['command', 'entryPoint'].includes(change.field) ? 100000 : 1024) || + ![change.before, change.after].every((value) => value === null || typeof value === 'string' && value.length <= (['command', 'entryPoint', 'targetServices'].includes(change.field) ? 100000 : 1024) || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) ) { throw new CommandError('INVALID_RESPONSE'); } + if ( change.field === 'hostname' && [change.before, change.after].some((value) => value !== null && !isExternalHostname(value)) ) { throw new CommandError('INVALID_RESPONSE'); } + if ( change.field === 'targetServices' ) { + if ( plan.resourceType !== 'dnsService' || change.after === null ) { throw new CommandError('INVALID_RESPONSE'); } + safeAliasTargetSummary(change.before); safeAliasTargetSummary(change.after); + } + if ( change.field === 'externalIpAddresses' ) { + for ( const value of [change.before, change.after] ) { + if ( value === null ) { continue; } + let addresses; + try { addresses = JSON.parse(value); } catch (error) { throw new CommandError('INVALID_RESPONSE'); } + if ( !Array.isArray(addresses) || addresses.length < 1 || addresses.length > 16 || new Set(addresses).size !== addresses.length || addresses.some((item) => !isExternalAddress(item)) ) { throw new CommandError('INVALID_RESPONSE'); } + } + } if ( ['command', 'entryPoint'].includes(change.field) ) { for ( const value of [change.before, change.after] ) { if ( value === null ) { continue; } @@ -730,10 +812,16 @@ export class CommandEngine { if ( Object.keys(parameters).some((field) => !descriptor.fields.includes(field)) || Object.keys(parameters).length < descriptor.minFields ) { throw new CommandError('INVALID_INPUT', 'parameters'); } + if ( input.resourceType === 'externalService' ) { validateExternalServiceParameters(input.operation, parameters); } + if ( input.resourceType === 'dnsService' && input.operation === 'update_targets' ) { + parameters.serviceIds = aliasTargetIds(parameters.serviceRefs, + (reference) => this.resolveResourceReference(reference, 'service', captured, 'parameters.serviceRefs')); + delete parameters.serviceRefs; + } let target; if ( input.operation === 'create' ) { if ( input.targetRef ) { throw new CommandError('INVALID_INPUT', 'targetRef'); } - if ( input.resourceType === 'service' ) { + if ( ['service', 'externalService', 'dnsService'].includes(input.resourceType) ) { if ( !input.parentRef ) { throw new CommandError('INVALID_INPUT', 'parentRef'); } const parent = this.resolveResourceReference(input.parentRef, 'stack', captured, 'parentRef'); if ( parent.resourceType !== 'stack' || parent.scope !== 'project' ) { throw new CommandError('INVALID_INPUT', 'parentRef'); } @@ -743,6 +831,9 @@ export class CommandEngine { if ( !input.targetRef || input.parentRef ) { throw new CommandError('INVALID_INPUT', input.parentRef ? 'parentRef' : 'targetRef'); } target = this.resolveResourceReference(input.targetRef, input.resourceType, captured, 'targetRef'); if ( target.resourceType !== input.resourceType || target.scope !== 'project' ) { throw new CommandError('INVALID_INPUT', 'targetRef'); } + if ( input.resourceType === 'dnsService' && parameters.serviceIds && parameters.serviceIds.includes(target.id) ) { + throw new CommandError('INVALID_INPUT', 'parameters.serviceRefs'); + } } const data = {resourceType: input.resourceType, operation: input.operation, parameters}; if ( target ) { data.resourceId = target.id; } @@ -752,7 +843,10 @@ export class CommandEngine { const planRef = this.context.reference('plan', reference, captured); const summary = this.resourcePlanSummary(plan, planRef); this.onPlan(Object.assign({context: captured}, summary)); - return Object.assign({status: 'needs_user', resourceTextIsUntrusted: true, nextStep: 'Ask the user to review and approve this exact change in the console.'}, summary); + return Object.assign({status: 'needs_user', resourceTextIsUntrusted: true, + ...(input.resourceType === 'dnsService' && input.operation === 'create' ? {workflow: [...ALIAS_WORKFLOW], targetsBound: false, + nextStep: `Ask the user to review and approve this create stage. ${ALIAS_CREATE_NEXT_STEP}`} : + {nextStep: 'Ask the user to review and approve this exact change in the console.'})}, summary); } async apply_resource_change(input, captured, signal) { diff --git a/app/utils/ai-external-service.js b/app/utils/ai-external-service.js new file mode 100644 index 0000000000..945c3a1b8b --- /dev/null +++ b/app/utils/ai-external-service.js @@ -0,0 +1,36 @@ +import {CommandError} from './ai-command-contract'; + +export function isExternalHostname(value) { + if ( typeof value !== 'string' || !value || value.length > 253 ) { return false; } + const host = value.endsWith('.') ? value.slice(0, -1) : value; + return !!host && host.split('.').every((label) => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label)); +} + +export function isExternalAddress(value) { + if ( typeof value !== 'string' || value.length > 45 ) { return false; } + const ipv4 = (text) => text.split('.').length === 4 && text.split('.').every((part) => /^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255); + if ( !value.includes(':') ) { return ipv4(value); } + if ( !/^[0-9A-Fa-f:.]+$/.test(value) || (value.match(/:/g) || []).length < 2 ) { return false; } + let normalized = value; + if ( value.includes('.') ) { + const last = value.lastIndexOf(':'), tail = value.slice(last + 1); + if ( !ipv4(tail) ) { return false; } + normalized = `${value.slice(0, last)}:0:0`; + } + if ( normalized.includes(':::') || normalized.split('::').length > 2 ) { return false; } + const parts = normalized.split(':').filter(Boolean); + return parts.every((part) => /^[0-9A-Fa-f]{1,4}$/.test(part)) && + (normalized.includes('::') ? parts.length < 8 : parts.length === 8 && !normalized.startsWith(':') && !normalized.endsWith(':')); +} + +export function validateExternalServiceParameters(operation, parameters) { + if ( !['create', 'update_targets'].includes(operation) ) { return; } + const hasHost = Object.prototype.hasOwnProperty.call(parameters, 'hostname'); + const hasIps = Object.prototype.hasOwnProperty.call(parameters, 'externalIpAddresses'); + if ( hasHost === hasIps ) { throw new CommandError('INVALID_INPUT', 'parameters'); } + if ( hasHost && !isExternalHostname(parameters.hostname) ) { throw new CommandError('INVALID_INPUT', 'parameters.hostname'); } + const ips = parameters.externalIpAddresses; + if ( hasIps && (!Array.isArray(ips) || ips.length < 1 || ips.length > 16 || ips.some((item) => !isExternalAddress(item)) || new Set(ips).size !== ips.length) ) { + throw new CommandError('INVALID_INPUT', 'parameters.externalIpAddresses'); + } +} diff --git a/app/utils/ai-resource-registry.js b/app/utils/ai-resource-registry.js index 707b825f4c..a274bbfd17 100644 --- a/app/utils/ai-resource-registry.js +++ b/app/utils/ai-resource-registry.js @@ -25,7 +25,7 @@ const family = (name, resources, coverageStatus = 'implemented', reason = '') => export const RESOURCE_FAMILIES = Object.freeze([ family('accounts', [global('account', 'accounts', ['kind'])]), family('api-keys', [global('apiKey', 'apikeys', ['kind', 'expiresAt'])]), - family('audit', [global('auditLog', 'auditLogs', ['eventType', 'resourceType', 'responseCode', 'httpStatus', 'authType', 'interactionChannel', 'decision', 'outcome', 'operation'])], 'pending-adapter', 'Engine audit metadata is implemented; the UI audit broker and combined Key/tool event query require a dedicated adapter.'), + family('audit', [global('auditLog', 'auditLogs', ['eventType', 'resourceType', 'responseCode', 'httpStatus', 'authType', 'interactionChannel', 'decision', 'outcome', 'operation'])], 'pending-adapter', 'Typed audit metadata queries are implemented through the matching Engine and broker owner for the native session and dedicated-Key bridge. Export and raw request/response payloads remain in the human audit UI; this is partial workflow coverage.'), family('authentication', [], 'pending-adapter', 'Authentication configuration uses its dedicated authenticated API. Login and credential entry remain human ceremonies.'), family('catalog', [catalog('catalogTemplate', ['name', 'description', 'catalogId', 'templateBase', 'templateKey', 'catalogScope', 'defaultVersion', 'maintainer', 'license']), catalog('catalogTemplateVersion', ['version', 'revision', 'minimumRancherVersion', 'maximumRancherVersion', 'upgradeFrom'], {type: 'catalogTemplate', field: 'templateId', required: true})], @@ -45,7 +45,7 @@ export const RESOURCE_FAMILIES = Object.freeze([ family('registry', [project('registry', 'registries')]), family('secrets', [project('secret', 'secrets')]), family('service', [project('service', 'services', ['scale', 'healthState', 'archived'], {type: 'stack', field: 'stackId'}), - project('dnsService', 'dnsServices', ['scale', 'healthState', 'archived'], {type: 'stack', field: 'stackId'}), project('externalService', 'externalServices', ['scale', 'healthState', 'archived'], {type: 'stack', field: 'stackId'})]), + resource('dnsService', 'dnsServices', 'project', ['scale', 'healthState', 'archived', 'targetServices', 'linkedServiceCount', 'unresolvedTargetCount'], {type: 'stack', field: 'stackId'}, ['Two-step Alias workflow: create makes an empty stopped Alias with no targets; update_targets needs selected same-session serviceRefs and another human-approved plan. Start is optional and separate. Named/ported/remote links and incoming-consumer cleanup remain pending.']), resource('externalService', 'externalServices', 'project', ['scale', 'healthState', 'archived', 'hostname', 'externalIpAddresses'], {type: 'stack', field: 'stackId'}, ['Typed changes cover name/description and safe DNS/IP targets. Health checks, cloning and advanced settings remain pending. Create is inactive until a separately approved start.'])]), family('settings', [global('setting', 'settings', ['source', 'inDb'])]), family('stack', [project('stack', 'stacks', ['system', 'archived'])]), family('storage', [project('volume', 'volumes', ['isHostPath', 'isNative']), @@ -70,11 +70,16 @@ export const RESOURCE_CHANGES = Object.freeze({ create_from_catalog: action(['name', 'description', 'startOnCreate', 'answers'], ['name']), update_metadata: metadata}), service: Object.freeze({start: action(), stop: action(), restart: action(), remove: action(), archive: action(), restore: action(), scale: action(['scale'], ['scale']), create: action(['name', 'image', 'scale', 'description', 'command', 'entryPoint'], ['name', 'image']), update_metadata: metadata}), + dnsService: Object.freeze({start: action(), stop: action(), remove: action(), update_metadata: metadata, + create: action(['name', 'description'], ['name']), update_targets: action(['serviceRefs'], ['serviceRefs'])}), + externalService: Object.freeze({start: action(), stop: action(), remove: action(), update_metadata: metadata, + create: action(['name', 'description', 'hostname', 'externalIpAddresses'], ['name']), + update_targets: action(['hostname', 'externalIpAddresses'], [], 1)}), container: Object.freeze({start: action(), stop: action(), restart: action(), remove: action(), create: action(['name', 'image', 'description', 'command', 'entryPoint'], ['name', 'image']), update_metadata: metadata}), }); export const CHANGE_OPERATIONS = Object.freeze([...new Set(Object.values(RESOURCE_CHANGES).flatMap((entry) => Object.keys(entry)))]); -export const RESOURCE_CHANGE_FIELDS = Object.freeze(['name', 'description', 'image', 'scale', 'command', 'entryPoint', 'stackId', 'archived', +export const RESOURCE_CHANGE_FIELDS = Object.freeze(['name', 'description', 'hostname', 'externalIpAddresses', 'targetServices', 'image', 'scale', 'command', 'entryPoint', 'stackId', 'archived', 'state', 'operation', 'childServiceState', 'startOnCreate', 'affectedServiceCount', 'emptyStack', 'affectedInstanceCount', 'affectedMountCount', 'mountedDataRule']); // Only scalar allowlisted values cross the boundary. Nested structures can @@ -83,11 +88,11 @@ export function safeResourceFields(body, descriptor) { const result = {}; for ( const field of descriptor.fields ) { const value = body[field]; - if ( typeof value === 'string' ) { result[field] = value.slice(0, field === 'description' ? 1024 : 512); } + if ( typeof value === 'string' ) { result[field] = value.slice(0, field === 'targetServices' ? 100000 : ['description', 'externalIpAddresses'].includes(field) ? 1024 : 512); } else if ( typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) || value === null ) { result[field] = value; } } return result; } export function truncatedResourceFields(body, descriptor) { - return descriptor.fields.filter((field) => typeof body[field] === 'string' && body[field].length > (field === 'description' ? 1024 : 512)); + return descriptor.fields.filter((field) => typeof body[field] === 'string' && body[field].length > (field === 'targetServices' ? 100000 : ['description', 'externalIpAddresses'].includes(field) ? 1024 : 512)); } diff --git a/app/utils/ai-resource-review-labels.js b/app/utils/ai-resource-review-labels.js index 9cea2ace4a..688b08d846 100644 --- a/app/utils/ai-resource-review-labels.js +++ b/app/utils/ai-resource-review-labels.js @@ -1,7 +1,7 @@ -const TYPES = {stack: 'apiKeyAccess.selector.types.stack', service: 'apiKeyAccess.selector.types.service', container: 'apiKeyAccess.selector.types.container'}; -const OPERATIONS = {start: 'action.start', stop: 'action.stop', restart: 'action.restart', remove: 'action.remove', +const TYPES = {dnsService: 'servicePage.type.dnsservice', externalService: 'servicePage.type.externalservice', stack: 'apiKeyAccess.selector.types.stack', service: 'apiKeyAccess.selector.types.service', container: 'apiKeyAccess.selector.types.container'}; +const OPERATIONS = {update_targets: 'formTarget.target', start: 'action.start', stop: 'action.stop', restart: 'action.restart', remove: 'action.remove', create: 'saveCancel.create', create_from_catalog: 'newCatalog.saveNew', update_metadata: 'generic.edit', scale: 'serviceInfoPartial.scale', archive: 'archive.action', restore: 'archive.restore'}; -const FIELDS = {name: 'generic.name', description: 'generic.description', image: 'generic.image', scale: 'serviceInfoPartial.scale', +const FIELDS = {targetServices: 'formTarget.target', hostname: 'formTarget.pointHostname', externalIpAddresses: 'formTarget.pointAddress', name: 'generic.name', description: 'generic.description', image: 'generic.image', scale: 'serviceInfoPartial.scale', state: 'generic.state', stackId: 'apiKeyAccess.selector.types.stack', command: 'sidekickAddtlInfo.command', entryPoint: 'sidekickAddtlInfo.entryPoint', operation: 'generic.actions', childServiceState: 'webmcp.childServiceState', affectedInstanceCount: 'webmcp.affectedInstanceCount', affectedMountCount: 'webmcp.affectedMountCount', startOnCreate: 'webmcp.startOnCreate', affectedServiceCount: 'webmcp.affectedServiceCount', emptyStack: 'webmcp.emptyStack', @@ -26,7 +26,7 @@ export function resourceReviewLabels(plan, translate) { }; return { resourceTypeLabel: text(label(TYPES, plan.resourceType)), operationLabel: text(label(OPERATIONS, plan.operation)), - createsStopped: plan.operation === 'create' && ['service', 'container'].includes(plan.resourceType), + createsStopped: plan.operation === 'create' && ['service', 'container', 'externalService', 'dnsService'].includes(plan.resourceType), archiveNotices: ['archive', 'restore'].includes(plan.operation) ? [text(plan.operation === 'archive' ? 'archive.explanation' : 'archive.restoreExplanation'), text(plan.operation === 'archive' ? 'archive.requiresStopped' : 'archive.restoreBeforeStart'), text('archive.dataWarning')] : [], mountedDataNotice: (plan.displayDiff || []).some((change) => change.field === 'mountedDataRule' && change.after === 'EXISTING_DOMAIN_RETENTION_NO_ADDITIONAL_PURGE') ? text('webmcp.domainRetentionNotice') : null, diff --git a/bridge/README.md b/bridge/README.md index 7d503f569d..1ab86804f8 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -5,7 +5,7 @@ command layer. It needs a compatible Engine with `api.ai.bridge.enabled=true`, a dedicated **AI collaboration bridge** Key and the parent repository's supported Node.js toolchain. It cannot approve its own change plans. -The current 0.2.2 candidate source contains twelve typed tools for resource-family +The current 0.2.3 candidate source contains twelve typed tools for resource-family discovery, safe metadata reads and stack/service/container lifecycle plans. Unsupported-operation replies explain that Global services follow eligible hosts rather than a manual instance count; they do not advise retrying the plan. @@ -15,6 +15,13 @@ expect this reply correction in the immutable 0.2.0 release archive. The generic stdio read adapter uses server-side safe views, never direct credential-bearing domain resources. Pending adapters are reported by `list_capabilities`. +External Services require an authorized Stack reference and exactly one target +mode (hostname or IPs). DNS Aliases are created stopped and empty; target binding +uses selected Service references in a separate approved plan. Typed audit queries +use the matching Engine 0.183.360 and broker owner, current Key scope and pinned +revision. Private audit payloads remain in the human UI. This candidate is not +published; use the latest published archive until qualification is complete. + The Catalog source adapter also needs a matching Engine/Catalog typed metadata endpoint. Use `find_resources` with `family: "catalog"` and `resourceType: "catalogTemplate"` to find a named template. Keep all same-name @@ -37,13 +44,13 @@ native browser support. The operator must separately opt in to `api.ai.bridge.enabled` and, for upgrade plans, `api.ai.service.upgrade.enabled`; both Engine flags default to false. -After qualification and publication, the source archive is `pasturestack-webmcp-bridge-0.2.2.zip` with a +After qualification and publication, the source archive is `pasturestack-webmcp-bridge-0.2.3.zip` with a `.zip.sha256` checksum. Extract the whole archive, keeping both `bridge/` and `app/utils/`; the bridge uses those exact shared command sources. With Node.js 24.22.1 available, install from the extracted directory: ```sh -cd pasturestack-webmcp-bridge-0.2.2/bridge +cd pasturestack-webmcp-bridge-0.2.3/bridge npm ci ``` @@ -52,7 +59,7 @@ records the included source hashes; the root also includes LICENSE and the bridge CycloneDX SBOM. To reproduce the source release from a checkout: ```sh -python scripts/package-webmcp-bridge.py --version 0.2.2 --sbom /path/to/webmcp-bridge.cdx.json --output /path/to/pasturestack-webmcp-bridge-0.2.2.zip +python scripts/package-webmcp-bridge.py --version 0.2.3 --sbom /path/to/webmcp-bridge.cdx.json --output /path/to/pasturestack-webmcp-bridge-0.2.3.zip ``` Store the following configuration outside the extracted source tree in a private diff --git a/bridge/core.mjs b/bridge/core.mjs index 0587afb2a4..77f919abed 100644 --- a/bridge/core.mjs +++ b/bridge/core.mjs @@ -79,6 +79,7 @@ export function allowedRequest(path, method, projectId, bootstrap = false) { if (typeof path !== 'string') return false; const parsed = new URL(path, 'https://local.invalid/'); if (parsed.origin !== 'https://local.invalid' || parsed.hash || parsed.pathname.slice(1) !== path.split('?')[0]) return false; + if (path === 'pasturestack/ai-audit-queries' && method === 'POST') return true; if (method === 'POST' && ['aiResourceViews', 'aiCatalogMetadataViews', 'aiCatalogStackLaunchOptions'].some(kind => parsed.pathname === `/projects/${projectId}/${kind}`) && !parsed.search) return true; if (bootstrap && method === 'GET' && parsed.pathname === '/projects') { return [...parsed.searchParams.keys()].every(key => ['limit', 'marker'].includes(key)); @@ -113,7 +114,8 @@ export function createTransport(config, fetcher = fetch) { let verifiedRevision = null; return async function request({url, method = 'GET', data, signal}, projectId, bootstrap = false) { if (!allowedRequest(url, method, projectId, bootstrap)) throw new CommandError('FORBIDDEN'); - const combined = signal ? AbortSignal.any([signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000); + const deadline = url === 'pasturestack/ai-audit-queries' ? 55000 : 15000; + const combined = signal ? AbortSignal.any([signal, AbortSignal.timeout(deadline)]) : AbortSignal.timeout(deadline); let response; try { response = await fetcher(new URL(url, base), {method, redirect: 'error', credentials: 'omit', signal: combined, diff --git a/bridge/package-lock.json b/bridge/package-lock.json index d02a4fbab2..06f8e47d2d 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@modelcontextprotocol/sdk": "1.32.2" }, diff --git a/bridge/package.json b/bridge/package.json index 4a44fc518e..ef8019acf1 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.2", + "version": "0.2.3", "private": true, "type": "module", "description": "Isolated stdio adapter for the shared PastureStack command contract", diff --git a/bridge/server.mjs b/bridge/server.mjs index bb18a337e9..7c64aaf836 100644 --- a/bridge/server.mjs +++ b/bridge/server.mjs @@ -7,7 +7,7 @@ try { const path = process.env.PASTURESTACK_BRIDGE_CONFIG; if (!path) throw new SetupError('CONFIG_REQUIRED'); const bridge = await createBridge(await loadConfig(path)); - const server = new Server({name: 'pasturestack-webmcp-bridge', version: '0.2.2'}, { + const server = new Server({name: 'pasturestack-webmcp-bridge', version: '0.2.3'}, { capabilities: {tools: {}}, instructions: 'Use names and returned opaque references. Never guess IDs. A human must approve change plans in the console. Rejected calls must be repaired only as directed; never evade permissions. Accepted/running is not success. Read the existing operation until verifiedResult=true.', }); diff --git a/bridge/shared-modules.mjs b/bridge/shared-modules.mjs index cc6075ef1c..8439375df4 100644 --- a/bridge/shared-modules.mjs +++ b/bridge/shared-modules.mjs @@ -10,6 +10,9 @@ const sources = Object.freeze({ 'ai-resource-registry': new URL('../app/utils/ai-resource-registry.js', import.meta.url), 'ai-catalog-reader': new URL('../app/utils/ai-catalog-reader.js', import.meta.url), 'ai-catalog-launch': new URL('../app/utils/ai-catalog-launch.js', import.meta.url), + 'ai-alias-service': new URL('../app/utils/ai-alias-service.js', import.meta.url), + 'ai-external-service': new URL('../app/utils/ai-external-service.js', import.meta.url), + 'ai-audit-reader': new URL('../app/utils/ai-audit-reader.js', import.meta.url), }); const urls = new Map(); const loading = new Set(); diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index c527be5726..cbf7ebd59f 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.200", + "version": "1.6.201", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.200", + "version": "1.6.201", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.201.md b/docs/releases/web-console-1.6.201.md new file mode 100644 index 0000000000..055424905f --- /dev/null +++ b/docs/releases/web-console-1.6.201.md @@ -0,0 +1,21 @@ +# Web Console 1.6.201 / bridge 0.2.3 candidate + +Not yet published or runtime accepted. Dependency graphs are unchanged. + +Twelve typed tools now include bounded External Service endpoint workflows, +stopped empty DNS Alias creation followed by separately approved target binding, +and fixed-owner audit metadata queries for browser and dedicated-Key stdio. +Opaque references retain same-name choices and native authorization. Unsupported +advanced links/incoming cleanup remain explicit. Raw IDs, URLs, secrets and audit +request/response payloads are not accepted as tool shortcuts. + +Bridge packaging includes the audit reader and direct tests; CI executes those +cases. Machine-readable audit guidance describes both supported transports and +explains matching-component errors without suggesting credential substitution. +Original source failures and their corrections remain in scoped QA evidence. + +Normal UI/session/Full/legacy behavior, human review, Catalog/Archive and default +off opt-ins are preserved. Engine 0.183.360 and matching broker are required. +Required full CI/CodeQL, original archive/SBOM and actual clean-install proof, +combined Server and browser/role/cross-ID/weak-model acceptance are pending. +This candidate does not complete the full frontend inventory. diff --git a/docs/webmcp.md b/docs/webmcp.md index ef137cc932..00bcde7b50 100644 --- a/docs/webmcp.md +++ b/docs/webmcp.md @@ -6,16 +6,17 @@ Ordinary console actions and general-purpose Keys retain their existing behavior ## Tools and workflow -The current source adds the five resource-family tools below to the seven -tools released in Web Console 1.6.197. These extensions require a matching -Engine; source and focused tests are not live acceptance or a published release. +The current 1.6.201 candidate retains the twelve tools below. Published 1.6.200 +remains the latest qualified client. New External Service, DNS Alias and audit +query workflows require matching Engine 0.183.360 / broker components; source +and focused tests are not live acceptance or a published release. | Tool | Purpose | | --- | --- | | `list_capabilities` | List 25 UI families, typed resources, read scope and explicit implemented/pending/human-ceremony coverage. | | `find_resources` | Find safe resource metadata, preserving duplicate candidates and parent/cursor scope. | | `get_resource` | Read safe metadata using an opaque reference; report any truncated fields explicitly. | -| `preview_resource_change` | Preview a supported stack, service or container lifecycle change for human review. | +| `preview_resource_change` | Preview a supported stack, service, External Service, DNS Alias or container change for human review. | | `apply_resource_change` | Apply that exact approved resource-change plan and return its recoverable operation reference. | | `find_stacks` | Find visible stacks by name and return opaque references. | | `find_services` | Find services, keeping same-name candidates separate. | @@ -56,9 +57,10 @@ which rechecks the normal resource manager, role and Key scope. Global metadata and selected-environment resources have separate descriptors. A view being implemented does not mean the current account is authorized: capabilities report `requires-server-check` and the actual request decides. Authentication -configuration, catalog, webhook and log adapters remain explicitly pending; -MFA/passkey ceremonies use the existing human flow. Engine audit metadata does -not claim to cover the UI's aggregated audit broker. No tool accepts raw IDs, +configuration, webhook and terminal/log workflows remain pending. MFA/passkey +ceremonies use the existing human flow. Typed audit queries use the matching +Engine/broker owner; raw audit payloads and export remain human-only. Catalog +metadata and its bounded launch adapter are described below. No tool accepts raw IDs, URLs, HTTP methods or caller-selected output fields. ## Browser and human controls @@ -103,7 +105,7 @@ Choose expiry and policy there. The owner's current role remains the ceiling. This Key purpose additionally limits requests to the implemented safe resource views, existing project/stack/service reads, description updates, typed plans -and tool-audit routes. It cannot +the fixed typed audit-query route and tool-audit routes. It cannot approve a plan, manage Keys, log in, execute commands or call arbitrary APIs. The bridge refuses general-purpose Keys. Purpose and revision come from the server's authenticated Key context, not a client assertion. A policy change @@ -179,5 +181,26 @@ Required component CI, published archive/SBOM verification and the standalone ZIP's clean install/48 focused adapter and SDK cases passed. Every new console page still starts with browser tools off and needs explicit opt-in. Combined Server and designated Catalog/Archive browser, role and weak-model acceptance -remain **PENDING**. The current 54 partially implemented source groups do not -establish acceptance of all 243 business groups. +remain **PENDING**. The published 1.6.200 overlay recorded 54 partially implemented source groups; +that historical count does not establish acceptance of all 243 business groups. + +## Candidate External Service, DNS Alias and audit workflows + +In 1.6.201 / bridge 0.2.3, External Service creation requires a selected Stack +reference and exactly one hostname/IP mode. Creation is stopped; start and +target updates need their own reviewed plans. DNS Alias creation is empty and +stopped. Read the resulting Service reference, select local target references, +then preview and approve update_targets separately. Advanced/remote links and +incoming-consumer/cross-source cleanup remain unsupported. + +For audit metadata use find_resources with family audit, resourceType auditLog +and typed auditFilters. Do not combine these with name query or parentRef. +Select Key/environment filters through their returned opaque references. Browser +and stdio use the matching fixed Engine/broker owner; current actor/Key policy +and revision are checked for every source page. A result-set-too-large response +requires a narrower range or environment; it is not an empty successful result. +Raw request/response payloads and export remain in the normal human audit UI. + +These source candidates preserve previous protections. Full component CI/CodeQL, +actual source-ZIP installation, combined runtime and designated role/cross-ID, +layout and weak-model workflows remain pending. diff --git a/package-lock.json b/package-lock.json index c527be5726..cbf7ebd59f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.200", + "version": "1.6.201", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.200", + "version": "1.6.201", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index be98244c35..c45240d9e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.200", + "version": "1.6.201", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index ec6a76b0ba..5430d79edc 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.200" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.200" +if [[ "$version" != "1.6.201" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.201" failures=$((failures + 1)) fi diff --git a/scripts/check-webmcp-engine-contract.mjs b/scripts/check-webmcp-engine-contract.mjs index 5843503a1b..764adeb1a9 100644 --- a/scripts/check-webmcp-engine-contract.mjs +++ b/scripts/check-webmcp-engine-contract.mjs @@ -30,12 +30,16 @@ const wireInputs={aiServiceUpgradePlan:['serviceId','image'],aiServiceDescriptio aiResourceChangePlan:['resourceType','operation','resourceId','parameters'],aiCatalogMetadataView:['query','marker','templateId','versions','versionId'], aiCatalogStackLaunchOptions:['templateId','versionId'], aiCatalogStackLaunchPlan:['templateId','versionId','optionsRef','name','description','startOnCreate','answers','referenceBindings'], - aiCatalogStackLaunchInputs:['answers','referenceBindings']}; + aiCatalogStackLaunchInputs:['answers','referenceBindings'], + aiAuditQuery:['keyId','environmentId','from','to','lastMinutes','decision','outcome','operation','targetType','requestId','httpStatus','limit','offset','eventId'], + aiAuditSourcePageInput:['limit','marker','projectId','accountId','from','to','keyKind']}; for(const [type,inputs] of Object.entries(wireInputs)){ if(catalogOptionsOnly&&type!=='aiCatalogStackLaunchOptions')continue; const schema=await read('base/'+type+'.json'); const actionInput=type==='aiCatalogStackLaunchInputs'; + const auditQuery=type==='aiAuditQuery',auditSource=type==='aiAuditSourcePageInput',audit=auditQuery||auditSource; if(actionInput){assert.deepEqual(schema.collectionMethods,[],'Human input must not have its own collection route');assert.deepEqual(schema.resourceMethods,[],'Human input must not have its own resource route');} + else if(auditSource){assert.deepEqual(schema.collectionMethods,[],'Private Audit input must not have its own collection route');assert.deepEqual(schema.resourceMethods,[],'Private Audit input must not have its own resource route');} else assert.ok(schema.collectionMethods.includes('POST'),type+' is not a POST input contract'); for(const name of inputs)assert.equal(schema.resourceFields[name]?.create,true,type+'.'+name+' is not accepted input'); if(type==='aiResourceChangePlan'){ @@ -53,6 +57,29 @@ for(const [type,inputs] of Object.entries(wireInputs)){ } assert.equal(schema.resourceFields.versions.type,'boolean','Catalog versions must be an explicit boolean'); } + if(audit){ + const outputs=auditQuery?{resourceType:'string',data:'array[json]',total:'int',nextOffset:'int',range:'map[string]',payloadPolicy:'string'}:{}; + assert.deepEqual(Object.keys(schema.resourceFields).sort(),[...inputs,...Object.keys(outputs)].sort(),type+' must retain its closed field set'); + assert.deepEqual(Object.keys(schema.resourceActions||{}),[],type+' must not expose resource actions'); + assert.deepEqual(Object.keys(schema.collectionActions||{}),[],type+' must not expose collection actions'); + if(auditQuery){ + assert.equal(schema.pluralName,'aiAuditQueries');assert.deepEqual(schema.collectionMethods,['POST'],type+' must be POST-only');assert.deepEqual(schema.resourceMethods,[],type+' must not expose resource methods'); + } + for(const name of inputs){ + const field=schema.resourceFields[name],kind=auditSource?(name==='limit'?'int':name==='keyKind'?'enum':'string'):['lastMinutes','httpStatus','limit','offset'].includes(name)?'int':'string'; + assert.equal(field.type,kind,type+'.'+name+' type mismatch'); + assert.notEqual(field.update,true,type+'.'+name+' must not be client-updatable'); + if(kind==='string')assert.equal(field.maxLength,auditSource?['from','to'].includes(name)?64:128:160,type+'.'+name+' bound mismatch'); + } + if(auditSource){ + assert.equal(schema.resourceFields.limit.min,1,type+'.limit minimum mismatch');assert.equal(schema.resourceFields.limit.max,1000,type+'.limit maximum mismatch'); + assert.deepEqual([...schema.resourceFields.keyKind.options].sort(),['apiKey','apiKeyRestricted'],type+'.keyKind enum mismatch'); + } + for(const [name,kind] of Object.entries(outputs)){ + const field=schema.resourceFields[name];assert.equal(field.type,kind,type+'.'+name+' type mismatch'); + assert.equal(field.create,false,type+'.'+name+' must be read-only output');assert.notEqual(field.update,true,type+'.'+name+' must not be client-updatable'); + } + } const catalogLaunch=type.startsWith('aiCatalogStackLaunch'); if(catalogLaunch){ const field=(name,kind)=>assert.equal(schema.resourceFields[name]?.type,kind,type+'.'+name+' type mismatch'); @@ -82,14 +109,17 @@ for(const [type,inputs] of Object.entries(wireInputs)){ } for(const role of ['admin','user','project']){ const {authorize}=await read(role+'/'+role+'-auth.json'); - assert.ok(authorize[type]?.includes(actionInput?'r':'c'),role+' does not expose '+type); + assert.ok(authorize[type]?.includes(actionInput||auditSource?'r':'c'),role+' does not expose '+type); + if(audit)assert.equal(authorize[type],auditSource?'r':'cr',role+' must retain the exact '+type+' route metadata'); if(catalogLaunch)assert.ok(authorize[type]?.includes('r'),role+' cannot retain '+type+' metadata'); for(const [name,field] of Object.entries(schema.resourceFields)){ if(field.create){ assert.ok(authorize[type+'.'+name]?.includes('c'),role+' cannot accept '+type+'.'+name); + if(audit)assert.equal(authorize[type+'.'+name],'cr',role+' must retain exact Audit input metadata '+type+'.'+name); if(catalogLaunch)assert.ok(authorize[type+'.'+name]?.includes('r'),role+' cannot retain input metadata '+type+'.'+name); } else if(catalogLaunch)assert.ok(authorize[type+'.'+name]?.includes('r'),role+' cannot read safe '+type+'.'+name); + if(audit&&!field.create)assert.equal(authorize[type+'.'+name],'r',role+' must retain read-only Audit output '+type+'.'+name); } } } diff --git a/scripts/ci b/scripts/ci index a87a6acbac..7aefee14ba 100755 --- a/scripts/ci +++ b/scripts/ci @@ -12,6 +12,9 @@ fi ./scripts/check-ui-ember-api-store-fetch-upgrade node --test scripts/test-webmcp-path-boundaries.mjs node --test scripts/test-ai-catalog-reader.mjs scripts/test-webmcp-catalog.mjs scripts/test-webmcp-catalog-launch.mjs scripts/test-webmcp-archive.mjs scripts/test-webmcp-resources.mjs +node --test scripts/test-webmcp-external-service.mjs +node --test scripts/test-webmcp-alias-service.mjs +node --test scripts/test-webmcp-audit.mjs scripts/test-webmcp-audit-extension.mjs node scripts/test-ai-collaboration-ui.js node scripts/test-ai-resource-review-labels.mjs ./scripts/check-ui-catalog-version-select diff --git a/scripts/package-webmcp-bridge.py b/scripts/package-webmcp-bridge.py index e37baa0afb..ca3c9eb12b 100644 --- a/scripts/package-webmcp-bridge.py +++ b/scripts/package-webmcp-bridge.py @@ -11,13 +11,17 @@ import zipfile -VERSION = "0.2.2" +VERSION = "0.2.3" SOURCE_FILES = ( "LICENSE", "app/utils/ai-command-context.js", "app/utils/ai-command-contract.js", "app/utils/ai-command-engine.js", "app/utils/ai-resource-registry.js", + "app/utils/ai-external-service.js", + "app/utils/ai-alias-service.js", + "app/utils/ai-audit-reader.js", + "app/utils/ai-resource-review-labels.js", "app/utils/ai-catalog-reader.js", "app/utils/ai-catalog-launch.js", "bridge/README.md", @@ -38,6 +42,10 @@ "scripts/test-webmcp-catalog-launch.mjs", "bridge/test/archive.test.mjs", "scripts/test-webmcp-archive.mjs", + "scripts/test-webmcp-external-service.mjs", + "scripts/test-webmcp-alias-service.mjs", + "scripts/test-webmcp-audit.mjs", + "scripts/test-webmcp-audit-extension.mjs", ) FORBIDDEN_PARTS = {"node_modules", ".git", "credentials", "credential", "secrets"} diff --git a/scripts/test-webmcp-alias-service.mjs b/scripts/test-webmcp-alias-service.mjs new file mode 100644 index 0000000000..7c4669ba0e --- /dev/null +++ b/scripts/test-webmcp-alias-service.mjs @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {CommandContext, CommandEngine} from '../bridge/shared-modules.mjs'; + +function fixture(aliasTargets = {}) { + const snapshot = {generation: 'g1', accountId: 'a1', projectId: 'p1', projectName: 'Environment', schemaProjectId: 'p1', schemaGeneration: 1, route: '/stacks'}; + let sequence = 0, nextId = 0, dispatches = 0, current; + const context = new CommandContext(() => snapshot, () => String(++sequence)); context.setMode('enabled'); + const requests = [], plans = new Map(); + const engine = new CommandEngine({context, transportKind: 'stdio', wait: async () => {}, ensureSession: async () => {}, + audit: async () => ({accepted: true, requestId: `audit${++sequence}`}), request: async request => { + requests.push(request); + if (request.url === 'projects/p1/aiResourceViews') { + const type = request.data.resourceType; + return {body: {resourceType: type, data: type === 'stack' ? [{id: 'st1', name: 'Blue'}, {id: 'st2', name: 'Green'}] + : type === 'dnsService' ? [{id: 'dns', name: 'alias', stackId: 'st1', stackName: 'Blue', parentResolution: 'resolved', state: 'inactive', ...aliasTargets}] + : [{id: 's1', name: 'db', stackId: 'st1', stackName: 'Blue', parentResolution: 'resolved'}, + {id: 's2', name: 'db', stackId: 'st2', stackName: 'Green', parentResolution: 'resolved'}]}}; + } + if (request.url === 'projects/p1/aiResourceChangePlans') { + const {resourceType, resourceId, operation, parameters} = request.data; + const id = `aichange-00000000-0000-0000-0000-${String(++nextId).padStart(12, '0')}`; + const changes = operation === 'create' ? [{field: 'name', before: null, after: parameters.name}, + {field: 'targetServices', before: null, after: '[]'}, {field: 'startOnCreate', before: null, after: false}] + : operation === 'update_targets' ? [{field: 'targetServices', before: '[]', after: JSON.stringify(parameters.serviceIds.map(id => id === 's2' ? 'Green / db' : 'Blue / db'))}] + : [{field: 'state', before: 'inactive', after: 'active'}]; + current = {id, resourceType, operation, resourceId: resourceId || null, targetPath: 'Environment / Blue / alias', changes, + confirmationState: 'pending', status: 'preview', verifiedResult: false, expiresAt: '2030-01-01T00:00:00Z'}; + plans.set(id, current); return {body: {...current}}; + } + const id = request.url.split('/').at(-1).split('?')[0], plan = plans.get(id); + if (!plan) throw new Error('Unexpected closed test transport'); + if (request.url.endsWith('?action=confirm')) { plan.confirmationState = 'approved'; return {body: {...plan}}; } + if (request.url.endsWith('?action=apply')) { + if (plan.confirmationState !== 'approved') throw {status: 409, body: {code: 'ResourceChangeNeedsConfirmation'}}; + dispatches++; plan.status = 'accepted'; return {body: {...plan}}; + } + return {body: {...plan}}; + }}); + return {snapshot, context, engine, requests, plan: () => current, dispatches: () => dispatches}; +} + +test('Alias create is explicitly only stage one, then exact same-name serviceRef binding needs another approval', async () => { + const f = fixture(); + const capability = (await f.engine.execute('list_capabilities', {})).families.find(family => family.family === 'service').resources.find(item => item.resourceType === 'dnsService'); + assert.deepEqual(capability.workflow, ['create', 'update_targets', 'optional_start']); assert.match(capability.workflowNotice, /no targets are bound/); + const stacks = await f.engine.execute('find_resources', {family: 'stack'}); + const create = await f.engine.execute('preview_resource_change', {resourceType: 'dnsService', operation: 'create', parentRef: stacks.items[0].resourceRef, parameters: {name: 'alias'}}); + assert.equal(create.status, 'needs_user'); assert.equal(create.targetsBound, false); assert.match(create.nextStep, /update_targets/); + assert.deepEqual(f.requests.at(-1).data.parameters, {name: 'alias', stackId: 'st1'}); + await f.engine.confirmPlan(create.planRef); + const accepted = await f.engine.execute('apply_resource_change', {planRef: create.planRef}); + f.plan().status = 'succeeded'; f.plan().verifiedResult = true; + const created = await f.engine.execute('get_operation', {operationRef: accepted.operationRef}); + assert.equal(created.status, 'succeeded'); assert.equal(created.targetsBound, false); assert.match(created.nextStep, /update_targets/); + const aliases = await f.engine.execute('find_resources', {family: 'service', resourceType: 'dnsService', query: 'alias'}); + const services = await f.engine.execute('find_resources', {family: 'service', resourceType: 'service', query: 'db'}); + assert.equal(services.items.length, 2); assert.notEqual(services.items[0].resourceRef, services.items[1].resourceRef); + const links = await f.engine.execute('preview_resource_change', {resourceType: 'dnsService', operation: 'update_targets', targetRef: aliases.items[0].resourceRef, + parameters: {serviceRefs: [services.items[1].resourceRef]}}); + assert.equal(links.status, 'needs_user'); assert.deepEqual(f.requests.at(-1).data.parameters, {serviceIds: ['s2']}); + assert.equal(links.displayDiff[0].after, '["Green / db"]'); assert.equal(JSON.stringify(links).includes('s2'), false); + assert.equal((await f.engine.execute('apply_resource_change', {planRef: links.planRef})).code, 'NEEDS_USER'); assert.equal(f.dispatches(), 1); + await f.engine.confirmPlan(links.planRef); await f.engine.execute('apply_resource_change', {planRef: links.planRef}); assert.equal(f.dispatches(), 2); +}); + +test('Alias targets reject raw, duplicate, foreign-family, self and stale references; empty explicitly clears', async () => { + const f = fixture(), captured = f.context.capture(); + const alias = f.context.reference('resource', {id: 'dns', family: 'service', resourceType: 'dnsService', scope: 'project'}, captured); + const service = f.context.reference('resource', {id: 's1', family: 'service', resourceType: 'service', scope: 'project'}, captured); + const duplicate = f.context.reference('service', {id: 's1'}, captured); + const host = f.context.reference('resource', {id: 'h1', family: 'host', resourceType: 'host', scope: 'project'}, captured); + const base = {resourceType: 'dnsService', operation: 'update_targets', targetRef: alias}; + for (const serviceRefs of [['s1'], [service, duplicate], [host], [alias], Array(17).fill(service)]) { + const result = await f.engine.execute('preview_resource_change', {...base, parameters: {serviceRefs}}); + assert.ok(['INVALID_INPUT', 'STALE_REFERENCE'].includes(result.code)); + } + assert.equal(f.requests.length, 0); + assert.equal((await f.engine.execute('preview_resource_change', {...base, parameters: {serviceRefs: []}})).status, 'needs_user'); + assert.deepEqual(f.requests.at(-1).data.parameters, {serviceIds: []}); + f.snapshot.generation = 'g2'; + assert.equal((await f.engine.execute('preview_resource_change', {...base, parameters: {serviceRefs: [service]}})).code, 'STALE_REFERENCE'); + assert.equal(f.requests.length, 1); +}); + +test('Alias review validates safe target arrays and reuses localized target labels without changing Catalog Archive or External', async () => { + const f = fixture(); + const plan = {id: 'aichange-00000000-0000-0000-0000-000000000001', resourceType: 'dnsService', operation: 'update_targets', confirmationState: 'pending', targetPath: 'Environment / Alias', + expiresAt: '2030-01-01T00:00:00Z', changes: [{field: 'targetServices', before: '[]', after: '["Stack / db"]'}]}; + assert.equal(f.engine.resourcePlanSummary(plan, 'opaque').displayDiff[0].after, '["Stack / db"]'); + for (const after of ['{}', '[{"id":"PRIVATE"}]', '[null]', JSON.stringify(Array(17).fill('db')), JSON.stringify(['a'.repeat(769)])]) + assert.throws(() => f.engine.resourcePlanSummary({...plan, changes: [{field: 'targetServices', before: '[]', after}]}, 'opaque')); + const source = readFileSync(new URL('../app/utils/ai-resource-review-labels.js', import.meta.url), 'utf8'); + const {resourceReviewLabels} = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + const labels = resourceReviewLabels({resourceType: 'dnsService', operation: 'update_targets', displayDiff: plan.changes}, key => key); + assert.equal(labels.resourceTypeLabel, 'servicePage.type.dnsservice'); assert.equal(labels.displayChanges[0].fieldLabel, 'formTarget.target'); + assert.equal(resourceReviewLabels({resourceType: 'dnsService', operation: 'create'}, key => key).createsStopped, true); + assert.equal(resourceReviewLabels({resourceType: 'externalService', operation: 'create'}, key => key).createsStopped, true); + assert.equal(resourceReviewLabels({resourceType: 'stack', operation: 'create_from_catalog'}, key => key).operationLabel, 'newCatalog.saveNew'); + assert.equal(resourceReviewLabels({resourceType: 'service', operation: 'restore'}, key => key).archiveNotices.length, 3); +}); + +test('Alias detail distinguishes empty resolved and hidden targets and rejects malformed summaries', async () => { + for (const [summary, availability, hint] of [ + [{linkedServiceCount: 0, unresolvedTargetCount: 0, targetServices: '[]'}, 'resolved', /no targets/], + [{linkedServiceCount: 1, unresolvedTargetCount: 0, targetServices: '["Green \/ db"]'}, 'resolved', /complete target names/], + [{linkedServiceCount: 1, unresolvedTargetCount: 1, targetServices: '[]'}, 'partial', /No hidden target names or IDs/], + [{}, 'unavailable', /not been resolved/], + ]) { + const f = fixture(summary), list = await f.engine.execute('find_resources', {family: 'service', resourceType: 'dnsService'}); + const detail = await f.engine.execute('get_resource', {resourceRef: list.items[0].resourceRef}); + assert.equal(detail.status, 'succeeded'); assert.equal(detail.targetBindings.availability, availability); assert.match(detail.nextStep, hint); + } + for (const summary of [{linkedServiceCount: 1, unresolvedTargetCount: 0, targetServices: '[]'}, + {linkedServiceCount: 1, unresolvedTargetCount: 0, targetServices: '[{"secret":"PRIVATE"}]'}, + {linkedServiceCount: -1, unresolvedTargetCount: 0, targetServices: '[]'}]) { + const f = fixture(summary), list = await f.engine.execute('find_resources', {family: 'service', resourceType: 'dnsService'}); + const detail = await f.engine.execute('get_resource', {resourceRef: list.items[0].resourceRef}); + assert.equal(detail.code, 'INVALID_RESPONSE'); assert.equal(JSON.stringify(detail).includes('PRIVATE'), false); + } +}); diff --git a/scripts/test-webmcp-audit-extension.mjs b/scripts/test-webmcp-audit-extension.mjs new file mode 100644 index 0000000000..118e01e105 --- /dev/null +++ b/scripts/test-webmcp-audit-extension.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CommandContext, CommandEngine} from '../bridge/shared-modules.mjs'; +import {allowedRequest, createTransport} from '../bridge/core.mjs'; + +const range = {from: '2026-10-01T00:00:00Z', to: '2026-10-02T00:00:00Z'}; +function fixture(handle) { + let n = 0; + const context = new CommandContext(() => ({generation: 'g', accountId: 'a1', projectId: 'p1', + schemaProjectId: 'p1', schemaGeneration: 1}), () => String(++n)); context.setMode('read-only'); + return new CommandEngine({context, transportKind: 'stdio', ensureSession: async () => {}, + audit: async () => ({accepted: true, requestId: 'safe'}), request: handle}); +} +const find = {family: 'audit', resourceType: 'auditLog', auditFilters: range}; +test('dedicated bridge adds exactly one typed audit route and pins verified revision', async () => { + for (const [path, method] of [['pasturestack/ai-audit-queries?url=x', 'POST'], ['pasturestack/ai-audit-queries/extra', 'POST'], + ['pasturestack/ai-audit-queries', 'GET'], ['pasturestack/audit-logs', 'GET'], ['auditlogs', 'GET'], + ['pasturestack/key-audit-logs', 'GET'], ['aiResourceViews', 'POST'], ['aiAuditQueries', 'POST']]) + assert.equal(allowedRequest(path, method, 'p1'), false, path); + let calls = 0; + const request = createTransport({endpoint: 'http://127.0.0.1:8080', accessKey: 'private-access', secretKey: 'private-secret'}, async (url, options) => { + assert.equal(url.pathname, calls ? '/v2-beta/pasturestack/ai-audit-queries' : '/v2-beta/projects'); + assert.equal(options.credentials, 'omit'); assert.equal(options.redirect, 'error'); + if (calls++) assert.equal(options.headers['X-PastureStack-Key-Expected-Revision'], '3'); + return new Response('{}', {headers: {'X-PastureStack-Key-Purpose': 'ai-bridge', 'X-PastureStack-Key-Revision': '3'}}); + }); + await request({url: 'projects'}, 'p1', true); + await request({url: 'pasturestack/ai-audit-queries', method: 'POST', data: range}, 'p1'); + assert.equal(calls, 2); +}); +test('stdio query and safe detail use unchanged frozen filter context without exposing IDs or payloads', async () => { + const calls = []; + const engine = fixture(async request => { + calls.push(request); assert.equal(request.url, 'pasturestack/ai-audit-queries'); assert.equal(request.method, 'POST'); + return {body: {resourceType: 'auditLog', data: [{id: '1al10', type: 'auditLog', accountId: 'p1', created: '2026-10-01T12:00:00Z', + payloadsIncluded: false, requestPayloadPresence: 'not_projected', responsePayloadPresence: 'not_projected'}], + total: 1, nextOffset: null, range, payloadPolicy: 'HUMAN_ONLY_NATIVE_AUDIT_UI'}}; + }); + const result = await engine.execute('find_resources', find); assert.equal(result.status, 'succeeded'); + const detail = await engine.execute('get_resource', {resourceRef: result.items[0].resourceRef}); assert.equal(detail.status, 'succeeded'); + assert.equal(calls.length, 2); assert.equal(calls[1].data.eventId, '1al10'); + assert.equal(JSON.stringify([result, detail]).includes('1al10'), false); assert.equal(detail.fields.payloadsIncluded, false); +}); +test('canonical query denial, narrow query, audit failure and unknown errors keep distinct bounded public results', async () => { + for (const [status, code, expected] of [[403, 'AuditQueryDenied', 'FORBIDDEN'], [422, 'AuditQueryNarrowQuery', 'INVALID_INPUT'], + [503, 'AuditQueryAuditUnavailable', 'UNAVAILABLE'], [503, 'AuditQueryUnavailable', 'UNAVAILABLE']]) { + const engine = fixture(async () => { throw {status, body: {code, message: 'PRIVATE-UPSTREAM'}}; }); + const result = await engine.execute('find_resources', find); + assert.equal(result.code, expected); assert.notEqual(result.status, 'succeeded'); + if (code === 'AuditQueryAuditUnavailable') assert.match(result.nextStep, /audit evidence/); + assert.equal(JSON.stringify(result).includes('PRIVATE'), false); + } +}); diff --git a/scripts/test-webmcp-audit.mjs b/scripts/test-webmcp-audit.mjs new file mode 100644 index 0000000000..e033c967b1 --- /dev/null +++ b/scripts/test-webmcp-audit.mjs @@ -0,0 +1,151 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CommandContext, CommandEngine, COMMANDS} from '../bridge/shared-modules.mjs'; +import {allowedRequest} from '../bridge/core.mjs'; + +const range = {from: '2026-10-01T00:00:00Z', to: '2026-10-02T00:00:00Z'}; +const secret = 'PRIVATE_PAYLOAD_MUST_NOT_REACH_MODEL'; +const row = (id = 'event1', extra = {}) => ({type: 'auditLog', id, accountId: 'p1', created: '2026-10-01T12:00:00Z', + eventType: 'api.service.update', decision: 'DENY', outcome: 'FAILED', httpStatus: 403, operation: 'update', + payloadsIncluded: false, requestPayloadPresence: 'present', responsePayloadPresence: 'not_projected', ...extra}); +const reply = (rows = [row()], extra = {}) => ({body: {resourceType: 'auditLog', data: rows, range, + total: rows.length, nextOffset: null, payloadPolicy: 'HUMAN_ONLY_NATIVE_AUDIT_UI', ...extra}}); + +function fixture(handle, transportKind = 'browser', audit) { + const snapshot = {generation: 'g1', accountId: 'a1', projectId: 'p1', projectName: 'Selected', + schemaProjectId: 'p1', schemaGeneration: 1, route: '/stacks'}; + let sequence = 0; + const context = new CommandContext(() => snapshot, () => String(++sequence)); context.setMode('read-only'); + const requests = [], audits = []; + const engine = new CommandEngine({context, transportKind, ensureSession: async () => {}, + audit: async event => { audits.push(event); if (audit) return audit(event); return {accepted: true, requestId: 'safe-request'}; }, + request: async request => { requests.push(request); return handle ? handle(request) : reply(); }}); + return {engine, context, snapshot, requests, audits}; +} +const find = (filters = {}, cursor) => ({family: 'audit', resourceType: 'auditLog', auditFilters: filters, ...(cursor ? {cursor} : {})}); + +test('audit workflow resolves real returned Key/project refs, freezes range and reauthorizes event detail', async () => { + const f = fixture(({url, method, data}) => { + if (url === 'projects/p1/aiResourceViews') { + assert.equal(data.resourceType, 'apiKey'); + return {body: {resourceType: 'apiKey', data: [{id: '1c99', name: 'My Key', secretValue: secret}], nextMarker: null}}; + } + if (url === 'projects?limit=50') return {body: {data: [{id: 'p2', name: 'Other visible environment'}]}}; + assert.equal(url, 'pasturestack/ai-audit-queries'); assert.equal(method, 'POST'); + assert.equal(data.keyId, '1c99'); assert.equal(data.environmentId, 'p2'); + assert.equal(data.from, range.from); assert.equal(data.to, range.to); + const index = data.eventId ? data.eventId : data.offset ? 'event2' : 'event1'; + return reply([row(index, {keyId: '1c99', accountId: 'p2'})], {total: data.eventId ? 1 : 2, + nextOffset: data.eventId || data.offset ? null : 1}); + }); + const keys = await f.engine.execute('find_resources', {family: 'api-keys'}); + const projects = await f.engine.execute('find_resources', {family: 'environment', resourceType: 'project'}); + const filters = {...range, keyRef: keys.items[0].resourceRef, environmentRef: projects.items[0].resourceRef, decision: 'DENY', limit: 1}; + const first = await f.engine.execute('find_resources', find(filters)); + assert.equal(first.status, 'succeeded'); assert.equal(first.complete, false); assert.equal(first.items.length, 1); + assert.equal(first.workflowCoverage, 'authorized-metadata-query'); assert.equal(first.items[0].fields.httpStatus, 403); + const next = await f.engine.execute('find_resources', find(filters, first.nextCursor)); + assert.equal(next.status, 'succeeded'); assert.equal(next.complete, true); + const detail = await f.engine.execute('get_resource', {resourceRef: first.items[0].resourceRef}); + assert.equal(detail.status, 'succeeded'); assert.equal(detail.fields.payloadsIncluded, false); + assert.equal(detail.payloadPolicy, 'HUMAN_ONLY_NATIVE_AUDIT_UI'); + assert.equal(JSON.stringify([first, next, detail, f.audits]).includes(secret), false); + assert.equal(JSON.stringify(first).includes('1c99'), false); +}); + +test('audit cursor cannot transfer filters or become a generic metadata cursor', async () => { + const f = fixture(() => reply([row()], {total: 2, nextOffset: 1})); + const first = await f.engine.execute('find_resources', find(range)); + assert.equal(first.status, 'succeeded'); + let result = await f.engine.execute('find_resources', find({...range, decision: 'ALLOW'}, first.nextCursor)); + assert.equal(result.code, 'INVALID_INPUT'); + result = await f.engine.execute('find_resources', {family: 'audit', cursor: first.nextCursor}); + assert.equal(result.code, 'INVALID_INPUT'); + assert.equal(f.requests.length, 1); +}); + +test('audit typed schema rejects raw IDs, arbitrary endpoints, wrong families/references and invalid ranges', async () => { + const f = fixture(); + const captured = f.context.capture(); + const wrong = f.context.reference('resource', {id: 's1', resourceType: 'service', scope: 'project'}, captured); + for (const input of [ + find({keyId: '1c99'}), find({url: 'https://outside.invalid'}), find({responseObject: true}), + {...find({}), family: 'service'}, {...find({}), query: 'name'}, find({keyRef: wrong}), + find({environmentRef: wrong}), find({from: range.from}), find({lastMinutes: 1, ...range}), + find({limit: 51}), find({decision: 'ALLOW OR DENY'}), + ]) { + const result = await f.engine.execute('find_resources', input); + assert.equal(result.code, 'INVALID_INPUT'); assert.equal(result.status, 'rejected'); + } + assert.equal(f.requests.length, 0); +}); + +test('audit stdio uses only the fixed typed owner route while raw audit routes remain excluded', async () => { + const f = fixture(undefined, 'stdio'); + const caps = await f.engine.execute('list_capabilities', {}); + const audit = caps.families.find(item => item.family === 'audit').resources[0]; + assert.equal(audit.typedAuditQuery.availability, 'implemented'); + assert.equal(audit.typedAuditQuery.transport, 'native-cookie-or-dedicated-api-key'); + const result = await f.engine.execute('find_resources', find({})); + assert.equal(result.status, 'succeeded'); + assert.match(result.nextStep, /current audit read scope/); assert.equal(f.requests.length, 1); + assert.equal(allowedRequest('pasturestack/ai-audit-queries', 'POST', 'p1'), true); + assert.equal(allowedRequest('pasturestack/audit-logs', 'GET', 'p1'), false); +}); + +test('audit known native denials stay rejected and scan-limit errors preserve actionable filters', async () => { + for (const [status, code] of [[401, 'SESSION_REQUIRED'], [403, 'FORBIDDEN'], [404, 'FORBIDDEN']]) { + const f = fixture(() => { throw {status, body: {code: 'audit_upstream_denied', message: secret}}; }); + const result = await f.engine.execute('find_resources', find({})); + assert.equal(result.code, code); assert.notEqual(result.status, 'succeeded'); + assert.equal(JSON.stringify([result, f.audits]).includes(secret), false); + } + const f = fixture(() => { throw {status: 422, body: {code: 'result_set_too_large', message: secret}}; }); + const result = await f.engine.execute('find_resources', find({lastMinutes: 60})); + assert.equal(result.code, 'INVALID_INPUT'); assert.equal(result.field, 'auditFilters.timeRange'); + assert.match(result.nextStep, /20,000-row/); assert.equal(f.requests.length, 1); +}); + +test('audit malformed metadata, widened range and broken pagination cannot leak private payload or false-complete', async () => { + for (const handle of [ + () => reply([row('event1', {responseObject: {secret}})]), + () => reply([row()], {range: {...range, secret}}), + () => reply([row()], {range: {...range, to: '2026-10-03T00:00:00Z'}}), + () => reply([row()], {total: 2, nextOffset: null}), + () => reply([row('event1', {accountId: 'foreign'})]), + ]) { + const f = fixture(handle); + const result = await f.engine.execute('find_resources', find(range)); + assert.equal(result.code, 'INVALID_RESPONSE'); assert.equal(JSON.stringify(result).includes(secret), false); + } +}); + +test('audit late actor/session response is discarded and start-audit failure gates the native query', async () => { + let finish; + const pending = new Promise(resolve => { finish = resolve; }); + const f = fixture(() => pending); + const running = f.engine.execute('find_resources', find({})); + while (!f.requests.length) await new Promise(resolve => setImmediate(resolve)); + f.snapshot.generation = 'g2'; f.snapshot.accountId = 'other'; + finish(reply()); + const result = await running; + assert.equal(result.code, 'CONTEXT_CHANGED'); assert.equal(result.items, undefined); + assert.equal(f.audits.length, 1); + const blocked = fixture(undefined, 'browser', () => { throw Error(secret); }); + assert.equal((await blocked.engine.execute('find_resources', find({}))).code, 'UNAVAILABLE'); + assert.equal(blocked.requests.length, 0); + assert.equal(COMMANDS.length, 12); +}); + +test('audit unavailable owner and invalid authority diagnostics remain bounded read failures', async () => { + for (const [status, nativeCode, expected] of [[502, 'audit_upstream_unavailable', 'UNAVAILABLE'], + [503, 'key_audit_unavailable', 'UNAVAILABLE'], [502, 'invalid_key_authority', 'INVALID_RESPONSE'], + [403, 'ai_audit_cookie_required', 'FORBIDDEN']]) { + const f = fixture(() => { throw {status, body: {code: nativeCode, message: secret}}; }); + const result = await f.engine.execute('find_resources', find({lastMinutes: 30})); + assert.equal(result.code, expected); assert.notEqual(result.status, 'succeeded'); + assert.equal(f.requests.length, 1); assert.equal(JSON.stringify([result, f.audits]).includes(secret), false); + if (expected === 'UNAVAILABLE') assert.match(result.nextStep, /temporarily unavailable/); + if (nativeCode === 'ai_audit_cookie_required') assert.match(result.nextStep, /matching Engine and broker audit-query components/); + } +}); diff --git a/scripts/test-webmcp-external-service.mjs b/scripts/test-webmcp-external-service.mjs new file mode 100644 index 0000000000..45b49272b7 --- /dev/null +++ b/scripts/test-webmcp-external-service.mjs @@ -0,0 +1,152 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {COMMANDS, CommandContext, CommandEngine} from '../bridge/shared-modules.mjs'; + +const planId = 'aichange-11111111-1111-1111-1111-111111111111'; +function fixture() { + const snapshot = {generation: 'g1', accountId: 'a1', projectId: 'p1', projectName: 'Environment', schemaProjectId: 'p1', schemaGeneration: 1, route: '/stacks'}; + let sequence = 0, plan, dispatches = 0; + const context = new CommandContext(() => snapshot, () => String(++sequence)); context.setMode('enabled'); + const requests = []; + const engine = new CommandEngine({context, wait: async () => {}, ensureSession: async () => {}, + audit: async () => ({accepted: true, requestId: `audit${++sequence}`}), request: async (request) => { + requests.push(request); + if (request.url.startsWith('projects/p1/stacks?')) return {body: {data: [{id: 'st1', name: 'Same'}, {id: 'st2', name: 'Same'}]}}; + if (request.url === 'projects/p1/aiResourceViews') return {body: {resourceType: 'externalService', data: [{id: 'external2', accountId: 'p1', name: 'orders-db', stackId: 'st2', stackName: 'Same', hostname: 'db.example.test', externalIpAddresses: null}]}}; + if (request.url === 'projects/p1/aiResourceChangePlans') { + const {resourceType, operation, resourceId, parameters} = request.data; + plan = {id: planId, resourceType, operation, resourceId: resourceId || null, targetPath: 'Environment/Same/orders-db', + changes: operation === 'create' ? [{field: 'name', before: null, after: parameters.name}, {field: 'hostname', before: null, after: parameters.hostname || null}, + {field: 'externalIpAddresses', before: null, after: parameters.externalIpAddresses ? JSON.stringify(parameters.externalIpAddresses) : null}, {field: 'startOnCreate', before: null, after: false}] + : [{field: 'state', before: 'inactive', after: 'active'}], + confirmationState: 'pending', status: 'preview', verifiedResult: false, expiresAt: '2026-10-30T12:00:00Z'}; + return {body: {...plan}}; + } + if (request.url.endsWith('?action=confirm')) { plan.confirmationState = 'approved'; return {body: {...plan}}; } + if (request.url.endsWith('?action=apply')) { + if (plan.confirmationState !== 'approved') throw {status: 409, body: {code: 'ResourceChangeNeedsConfirmation'}}; + dispatches++; plan.status = 'accepted'; return {body: {...plan}}; + } + if (request.url.endsWith(planId)) return {body: {...plan}}; + throw Error('Unexpected fixed transport'); + }}); + return {snapshot, context, engine, requests, plan: () => plan, dispatches: () => dispatches}; +} + +test('external service: exact selected opaque Stack parent creates a stopped native intent', async () => { + const f = fixture(); + const stacks = await f.engine.execute('find_resources', {family: 'stack', query: 'Same'}); + assert.equal(stacks.items.length, 2); assert.notEqual(stacks.items[0].resourceRef, stacks.items[1].resourceRef); + const preview = await f.engine.execute('preview_resource_change', {resourceType: 'externalService', operation: 'create', + parentRef: stacks.items[1].resourceRef, parameters: {name: 'orders-db', hostname: 'db.example.test'}}); + assert.equal(preview.status, 'needs_user'); + assert.equal(f.requests.at(-1).data.parameters.stackId, 'st2'); + assert.equal(Object.hasOwn(f.requests.at(-1).data.parameters, 'image'), false); + assert.equal(JSON.stringify(preview).includes('st2'), false); assert.equal(JSON.stringify(preview).includes(planId), false); + assert.equal(preview.displayDiff.find(item => item.field === 'startOnCreate').after, false); +}); + +test('external service: URL, raw IDs, mixed modes, unsafe IPs and unknown configuration never dispatch', async () => { + const f = fixture(); const captured = f.context.capture(); + const parentRef = f.context.reference('resource', {id: 'st2', family: 'stack', resourceType: 'stack', scope: 'project'}, captured); + const base = {resourceType: 'externalService', operation: 'create', parentRef, parameters: {name: 'db', hostname: 'db.test'}}; + for (const delta of [{parentRef: 'st2'}, {parameters: {name: 'db'}}, {parameters: {...base.parameters, externalIpAddresses: ['192.0.2.1']}}, + {parameters: {...base.parameters, healthCheck: {url: 'PRIVATE'}}}, {parameters: {...base.parameters, stackId: 'other'}}]) { + assert.equal((await f.engine.execute('preview_resource_change', {...base, ...delta})).code, delta.parentRef === 'st2' ? 'STALE_REFERENCE' : 'INVALID_INPUT'); + } + for (const hostname of ['https://db.test', 'user:PRIVATE@db.test', 'db:443', 'db/path', 'db?secret', '*.db', 'bad\nname']) { + assert.equal((await f.engine.execute('preview_resource_change', {...base, parameters: {name: 'db', hostname}})).code, 'INVALID_INPUT'); + } + for (const externalIpAddresses of [[], ['db.test'], ['256.1.1.1'], ['::1%eth0'], [':::1'], ['[::1]'], ['192.0.2.1:80'], ['192.0.2.1', '192.0.2.1']]) { + assert.equal((await f.engine.execute('preview_resource_change', {...base, parameters: {name: 'db', externalIpAddresses}})).code, 'INVALID_INPUT'); + } + assert.equal(f.requests.length, 0); +}); + +test('external service: current safe lookup -> separate approved activation -> verified operation, no raw mutation', async () => { + const f = fixture(); + const found = await f.engine.execute('find_resources', {family: 'service', resourceType: 'externalService', query: 'orders-db'}); + assert.equal(found.items.length, 1); + const detail = await f.engine.execute('get_resource', {resourceRef: found.items[0].resourceRef}); + assert.equal(detail.fields.hostname, 'db.example.test'); + const preview = await f.engine.execute('preview_resource_change', {resourceType: 'externalService', operation: 'start', targetRef: found.items[0].resourceRef}); + assert.equal(preview.status, 'needs_user'); + assert.equal((await f.engine.execute('apply_resource_change', {planRef: preview.planRef})).code, 'NEEDS_USER'); + assert.equal(f.dispatches(), 0); await f.engine.confirmPlan(preview.planRef); + const accepted = await f.engine.execute('apply_resource_change', {planRef: preview.planRef}); + assert.equal(accepted.status, 'accepted'); assert.equal(f.dispatches(), 1); + f.plan().status = 'succeeded'; f.plan().verifiedResult = true; + const observed = await f.engine.execute('get_operation', {operationRef: accepted.operationRef}); + assert.equal(observed.status, 'succeeded'); assert.equal(observed.verifiedResult, true); assert.equal(f.dispatches(), 1); + assert.ok(f.requests.every(request => request.method !== 'PUT' && !request.url.includes('/externalServices'))); +}); + +test('external service: exact bounded target summaries and stale actor refs fail closed', async () => { + const f = fixture(); const parentRef = f.context.reference('resource', {id: 'st2', family: 'stack', resourceType: 'stack', scope: 'project'}, f.context.capture()); + const preview = await f.engine.execute('preview_resource_change', {resourceType: 'externalService', operation: 'create', parentRef, + parameters: {name: 'db', externalIpAddresses: ['192.0.2.1', '2001:db8::1', '::ffff:192.0.2.1']}}); + assert.equal(preview.status, 'needs_user'); + for (const after of ['["https://PRIVATE@host"]', '[]', '[":::1"]', '{}', 'PRIVATE']) { + assert.throws(() => f.engine.resourcePlanSummary({...f.plan(), changes: [{field: 'externalIpAddresses', before: null, after}]}, 'ref')); + } + f.snapshot.generation = 'replacement'; + assert.equal((await f.engine.execute('apply_resource_change', {planRef: preview.planRef})).code, 'STALE_REFERENCE'); + assert.equal(f.dispatches(), 0); +}); + +test('external service: human review labels describe actual type, target fields and stopped creation', async () => { + const source = readFileSync(new URL('../app/utils/ai-resource-review-labels.js', import.meta.url), 'utf8'); + const {resourceReviewLabels} = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + const labels = resourceReviewLabels({resourceType: 'externalService', operation: 'update_targets', displayDiff: [{field: 'hostname', before: null, after: 'db.test'}, + {field: 'externalIpAddresses', before: '["192.0.2.1"]', after: null}]}, key => key); + assert.equal(labels.resourceTypeLabel, 'servicePage.type.externalservice'); assert.equal(labels.operationLabel, 'formTarget.target'); + assert.deepEqual(labels.displayChanges.map(item => item.fieldLabel), ['formTarget.pointHostname', 'formTarget.pointAddress']); + assert.equal(resourceReviewLabels({resourceType: 'externalService', operation: 'create'}, key => key).createsStopped, true); +}); + +test('external service integration: endpoint editing and deletion retain Catalog Archive and the bound failed receipt', async () => { + const registrySource = readFileSync(new URL('../app/utils/ai-resource-registry.js', import.meta.url), 'utf8'); + const {RESOURCE_CHANGES} = await import(`data:text/javascript;base64,${Buffer.from(registrySource).toString('base64')}`); + assert.ok(RESOURCE_CHANGES.stack.create_from_catalog); assert.ok(RESOURCE_CHANGES.stack.archive); + assert.ok(RESOURCE_CHANGES.service.restore); + assert.ok(COMMANDS.find(item => item.name === 'preview_resource_change').inputSchema.properties.catalogVersionRef); + const f = fixture(), original = f.engine.request; + f.engine.request = async request => { + const result = await original(request); + if (request.url === 'projects/p1/aiResourceChangePlans') { + const {operation, parameters} = request.data; + f.plan().changes = operation === 'update_targets' ? [ + {field: 'hostname', before: 'db.example.test', after: parameters.hostname || null}, + {field: 'externalIpAddresses', before: null, after: parameters.externalIpAddresses ? JSON.stringify(parameters.externalIpAddresses) : null}, + ] : operation === 'update_metadata' ? [{field: 'description', before: null, after: parameters.description}] + : operation === 'remove' ? [{field: 'affectedInstanceCount', before: 0, after: 0}, + {field: 'affectedMountCount', before: 0, after: null}, + {field: 'mountedDataRule', before: null, after: 'EXISTING_DOMAIN_RETENTION_NO_ADDITIONAL_PURGE'}] + : [{field: 'state', before: 'active', after: 'inactive'}]; + return {body: {...f.plan()}}; + } + return result; + }; + const found = await f.engine.execute('find_resources', {family: 'service', resourceType: 'externalService', query: 'orders-db'}); + const targetRef = found.items[0].resourceRef; + for (const [operation, parameters] of [['update_targets', {externalIpAddresses: ['192.0.2.1', '2001:db8::1']}], + ['update_targets', {hostname: 'new.example.test'}], ['update_metadata', {description: ''}], ['remove', {}]]) { + const preview = await f.engine.execute('preview_resource_change', {resourceType: 'externalService', operation, targetRef, parameters}); + assert.equal(preview.status, 'needs_user'); + assert.deepEqual(f.requests.at(-1).data, {resourceType: 'externalService', operation, resourceId: 'external2', parameters}); + } + const preview = await f.engine.execute('preview_resource_change', {resourceType: 'externalService', operation: 'stop', targetRef}); + await f.engine.confirmPlan(preview.planRef); + const accepted = await f.engine.execute('apply_resource_change', {planRef: preview.planRef}); + f.plan().status = 'failed'; f.plan().verifiedResult = false; f.plan().messageCode = 'ResourceChangeAuthorizationDenied'; + const failed = await f.engine.execute('get_operation', {operationRef: accepted.operationRef}); + assert.equal(failed.status, 'failed'); assert.equal(failed.code, 'FORBIDDEN'); + assert.equal(failed.partialEffectsPossible, true); assert.equal(failed.verifiedResult, false); + assert.equal(failed.operationRef, accepted.operationRef); assert.equal(f.dispatches(), 1); + assert.match(failed.nextStep, /Do not blindly resubmit/); + const labelsSource = readFileSync(new URL('../app/utils/ai-resource-review-labels.js', import.meta.url), 'utf8'); + const {resourceReviewLabels} = await import(`data:text/javascript;base64,${Buffer.from(labelsSource).toString('base64')}`); + assert.equal(resourceReviewLabels({resourceType: 'service', operation: 'archive'}, key => key).archiveNotices.length, 3); + assert.equal(resourceReviewLabels({resourceType: 'stack', operation: 'create_from_catalog'}, key => key).operationLabel, 'newCatalog.saveNew'); +}); diff --git a/scripts/test-webmcp-path-boundaries.mjs b/scripts/test-webmcp-path-boundaries.mjs index 9b62a2daac..23c74fea72 100644 --- a/scripts/test-webmcp-path-boundaries.mjs +++ b/scripts/test-webmcp-path-boundaries.mjs @@ -54,26 +54,38 @@ test('config rejects replacement before open and bounds a concurrent growth read async function fixture(dir){ const schema=path.join(dir,'resources/content/schema'); const names=['aiServiceUpgradePlan','aiServiceDescriptionUpdate','aiToolCallEvent','aiResourceView','aiResourceChangePlan','aiCatalogMetadataView', - 'aiCatalogStackLaunchOptions','aiCatalogStackLaunchPlan','aiCatalogStackLaunchInputs']; + 'aiCatalogStackLaunchOptions','aiCatalogStackLaunchPlan','aiCatalogStackLaunchInputs','aiAuditQuery','aiAuditSourcePageInput']; const inputs={aiServiceUpgradePlan:['serviceId','image'],aiServiceDescriptionUpdate:['serviceId','description','expectedDescription'], aiToolCallEvent:['command','phase','status','reason','correlationId'],aiResourceView:['resourceType','resourceId','query','marker','parentId'], aiResourceChangePlan:['resourceType','operation','resourceId','parameters'],aiCatalogMetadataView:['query','marker','templateId','versions','versionId'], aiCatalogStackLaunchOptions:['templateId','versionId'], aiCatalogStackLaunchPlan:['templateId','versionId','optionsRef','name','description','startOnCreate','answers','referenceBindings'], - aiCatalogStackLaunchInputs:['answers','referenceBindings']}; + aiCatalogStackLaunchInputs:['answers','referenceBindings'], + aiAuditQuery:['keyId','environmentId','from','to','lastMinutes','decision','outcome','operation','targetType','requestId','httpStatus','limit','offset','eventId'], + aiAuditSourcePageInput:['limit','marker','projectId','accountId','from','to','keyKind']}; const declarations=new Map(); for(const part of ['base','admin','user','project'])await fs.mkdir(path.join(schema,part),{recursive:true}); for(const name of names){ const fields=Object.fromEntries(inputs[name].map(field=>[field,{create:true}])); if(name==='aiToolCallEvent'){fields.command.options=[...COMMANDS.map(x=>x.name),'unknown'];fields.reason.options=[...PUBLIC_ERROR_CODES,'NONE'];} if(name==='aiResourceChangePlan'){ - fields.resourceType.options=['stack','service','container'];fields.operation.options=['start','stop','restart','remove','create','update_metadata','scale','archive','restore'];fields.parameters.type='json'; + fields.resourceType.options=['stack','service','container','externalService','dnsService'];fields.operation.options=['start','stop','restart','remove','create','update_metadata','update_targets','scale','archive','restore'];fields.parameters.type='json'; } if(name==='aiCatalogMetadataView'){ for(const [field,limit] of Object.entries({query:256,marker:128,templateId:68,versionId:68}))Object.assign(fields[field],{type:'string',maxLength:limit}); fields.versions.type='boolean'; } const declaration={collectionMethods:['POST'],resourceMethods:[],resourceFields:fields}; + if(name==='aiAuditQuery'){ + declaration.pluralName='aiAuditQueries'; + for(const key of inputs[name])Object.assign(fields[key],['lastMinutes','httpStatus','limit','offset'].includes(key)?{type:'int'}:{type:'string',maxLength:160}); + for(const [key,type] of Object.entries({resourceType:'string',data:'array[json]',total:'int',nextOffset:'int',range:'map[string]',payloadPolicy:'string'}))fields[key]={type,create:false}; + } + if(name==='aiAuditSourcePageInput'){ + declaration.collectionMethods=[];Object.assign(fields.limit,{type:'int',min:1,max:1000}); + for(const [key,maxLength] of Object.entries({marker:128,projectId:128,accountId:128,from:64,to:64}))Object.assign(fields[key],{type:'string',maxLength}); + Object.assign(fields.keyKind,{type:'enum',options:['apiKey','apiKeyRestricted']}); + } if(name.startsWith('aiCatalogStackLaunch')){ for(const key of ['templateId','versionId'])if(fields[key])Object.assign(fields[key],{type:'string',required:true,maxLength:68}); if(fields.answers)fields.answers.type='map[json]';if(fields.referenceBindings)fields.referenceBindings.type='map[string]'; @@ -93,8 +105,8 @@ async function fixture(dir){ await fs.writeFile(path.join(schema,'base',name+'.json'),JSON.stringify(declaration)); } for(const role of ['admin','user','project'])await fs.writeFile(path.join(schema,role,role+'-auth.json'),JSON.stringify({authorize:Object.fromEntries(names.flatMap(name=>[ - [name,name==='aiCatalogStackLaunchInputs'?'r':name.startsWith('aiCatalogStackLaunch')?'cr':'c'], - ...Object.entries(declarations.get(name).resourceFields).map(([field,value])=>[name+'.'+field,value.create?(name.startsWith('aiCatalogStackLaunch')?'cr':'c'):'r'])]))})); + [name,['aiCatalogStackLaunchInputs','aiAuditSourcePageInput'].includes(name)?'r':name.startsWith('aiCatalogStackLaunch')||name==='aiAuditQuery'?'cr':'c'], + ...Object.entries(declarations.get(name).resourceFields).map(([field,value])=>[name+'.'+field,value.create?(name.startsWith('aiCatalogStackLaunch')||name.startsWith('aiAudit')?'cr':'c'):'r'])]))})); return schema; } test('checker reads the fixed contract in an explicitly selected Engine checkout',async t=>{ @@ -130,6 +142,41 @@ test('checker rejects a public capacity error missing from the durable audit enu await assert.rejects(checkEngineContract(dir),/Public error CAPACITY cannot be audited/); }); +test('checker enforces closed Audit methods inputs outputs and metadata in every role',async t=>{ + const dir=await temp(t),schema=await fixture(dir);await checkEngineContract(dir); + const change=async(relative,mutate,pattern)=>{ + const file=path.join(schema,relative),original=await fs.readFile(file,'utf8'),value=JSON.parse(original); + mutate(value);await fs.writeFile(file,JSON.stringify(value)); + try{await assert.rejects(checkEngineContract(dir),pattern);}finally{await fs.writeFile(file,original);} + }; + for(const [mutate,pattern] of [ + [value=>{value.collectionMethods=['GET','POST'];},/aiAuditQuery must be POST-only/], + [value=>{value.resourceMethods=['GET'];},/aiAuditQuery must not expose resource methods/], + [value=>{value.resourceActions={read:{}};},/aiAuditQuery must not expose resource actions/], + [value=>{value.resourceFields.eventId.create=false;},/aiAuditQuery.eventId is not accepted input/], + [value=>{value.resourceFields.payload={type:'json',create:true};},/aiAuditQuery must retain its closed field set/], + [value=>{value.resourceFields.eventId.type='json';},/aiAuditQuery.eventId type mismatch/], + [value=>{value.resourceFields.requestId.maxLength=4096;},/aiAuditQuery.requestId bound mismatch/], + [value=>{value.resourceFields.payloadPolicy.create=true;},/aiAuditQuery.payloadPolicy must be read-only output/], + [value=>{value.resourceFields.data.update=true;},/aiAuditQuery.data must not be client-updatable/], + [value=>{value.resourceFields.data.type='json';},/aiAuditQuery.data type mismatch/], + ])await change('base/aiAuditQuery.json',mutate,pattern); + for(const [mutate,pattern] of [ + [value=>{value.collectionMethods=['POST'];},/Private Audit input must not have its own collection route/], + [value=>{value.resourceMethods=['GET'];},/Private Audit input must not have its own resource route/], + [value=>{value.resourceFields.limit.max=1001;},/aiAuditSourcePageInput.limit maximum mismatch/], + [value=>{value.resourceFields.marker.type='json';},/aiAuditSourcePageInput.marker type mismatch/], + [value=>{value.resourceFields.keyKind.options.push('token');},/aiAuditSourcePageInput.keyKind enum mismatch/], + ])await change('base/aiAuditSourcePageInput.json',mutate,pattern); + for(const role of ['admin','user','project'])for(const [field,permission,pattern] of [ + ['aiAuditQuery','crud',/must retain the exact aiAuditQuery route metadata/], + ['aiAuditQuery.eventId','c',/must retain exact Audit input metadata aiAuditQuery.eventId/], + ['aiAuditQuery.data','cr',/must retain read-only Audit output aiAuditQuery.data/], + ['aiAuditSourcePageInput','cr',/must retain the exact aiAuditSourcePageInput route metadata/], + ['aiAuditSourcePageInput.keyKind','c',/must retain exact Audit input metadata aiAuditSourcePageInput.keyKind/], + ])await change(role+'/'+role+'-auth.json',value=>{value.authorize[field]=permission;},pattern); +}); + test('checker rejects missing Catalog version inputs and unbounded or wrongly typed selectors',async t=>{ const dir=await temp(t),schema=await fixture(dir),file=path.join(schema,'base/aiCatalogMetadataView.json'); const original=JSON.parse(await fs.readFile(file,'utf8'));