sap-ai-dev-toolkit 0.9.9918 → 0.9.9920

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.github/agents/sap-integration-architect.agent.md +32 -18
  2. package/.github/agents/sap-integration-developer.agent.md +28 -12
  3. package/.github/skills/integration-suite-delivery/SKILL.md +94 -37
  4. package/.github/skills/sap-integration-architect/SKILL.md +49 -25
  5. package/README.md +72 -15
  6. package/dist/checksums.txt +5 -5
  7. package/dist/vsp-darwin-arm64 +0 -0
  8. package/dist/vsp-darwin-x64 +0 -0
  9. package/dist/vsp-linux-arm64 +0 -0
  10. package/dist/vsp-linux-x64 +0 -0
  11. package/dist/vsp-win32-x64.exe +0 -0
  12. package/inventory.md +97 -57
  13. package/package.json +1 -1
  14. package/scripts/check-live-integration.mjs +151 -0
  15. package/scripts/install-local.mjs +11 -4
  16. package/scripts/run-live-integration.mjs +48 -56
  17. package/src/apim-bundle.mjs +416 -13
  18. package/src/apim-cleanup-tools.mjs +145 -0
  19. package/src/apim-content.mjs +157 -0
  20. package/src/apim-delete-tools.mjs +139 -0
  21. package/src/apim-documentation.mjs +162 -0
  22. package/src/apim-inspection.mjs +141 -0
  23. package/src/apim-monitoring.mjs +140 -0
  24. package/src/apim-policy-steps.mjs +31 -0
  25. package/src/apim-runtime.mjs +262 -0
  26. package/src/apim-tools.mjs +202 -109
  27. package/src/cf-integration.mjs +46 -7
  28. package/src/cpi-async-monitoring.mjs +177 -0
  29. package/src/cpi-content.mjs +92 -0
  30. package/src/cpi-monitoring.mjs +225 -0
  31. package/src/cpi-operational.mjs +129 -0
  32. package/src/cpi-release.mjs +273 -0
  33. package/src/cpi-security.mjs +48 -0
  34. package/src/cpi-tools.mjs +184 -319
  35. package/src/devhub-retirement.mjs +345 -0
  36. package/src/devhub-tools.mjs +177 -0
  37. package/src/edge-monitoring.mjs +101 -0
  38. package/src/iflow-bundle.mjs +295 -7
  39. package/src/integration-alerts.mjs +248 -0
  40. package/src/integration-analysis.mjs +488 -0
  41. package/src/integration-capabilities.mjs +181 -0
  42. package/src/integration-collections.mjs +203 -0
  43. package/src/integration-common.mjs +140 -35
  44. package/src/integration-config.mjs +20 -14
  45. package/src/integration-credentials.mjs +16 -10
  46. package/src/integration-http.mjs +255 -67
  47. package/src/integration-messaging.mjs +118 -0
  48. package/src/integration-optional-config.mjs +122 -0
  49. package/src/integration-optional-tools.mjs +153 -0
  50. package/src/integration-release-store.mjs +410 -0
  51. package/src/integration-server.mjs +34 -16
  52. package/src/integration-state.mjs +31 -0
  53. package/src/integration-tools.mjs +44 -8
  54. package/src/integration-transport.mjs +184 -0
  55. package/src/integration-validation.mjs +26 -0
  56. package/src/integration-verify.mjs +143 -62
  57. package/src/open-connectors-tools.mjs +143 -0
  58. package/src/setup.mjs +66 -44
  59. package/tools.md +193 -26
@@ -0,0 +1,184 @@
1
+ // Read-only source/node evidence. Content Agent assembly and cTMS promotion remain native.
2
+ import { z } from 'zod';
3
+ import { READ_ONLY_ANNOTATIONS, key } from './integration-common.mjs';
4
+ import { createCollectionPager, cursorSchema, decodeCollection } from './integration-collections.mjs';
5
+ import { CPI_BASE, CPI_PACKAGE_ARTIFACT_SETS } from './cpi-content.mjs';
6
+ import { checkReleaseReadiness } from './integration-analysis.mjs';
7
+ import { snapshotEvidenceForTargets } from './integration-release-store.mjs';
8
+ import { capabilityCatalog } from './integration-capabilities.mjs';
9
+ import { IntegrationError } from './integration-http.mjs';
10
+
11
+ const packageId = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,59}$/);
12
+ const proxyName = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
13
+ const artifactId = /^[A-Za-z][A-Za-z0-9_.-]{0,99}$/;
14
+ const version = /^\d+\.\d+\.\d+$/;
15
+ const nodeName = z.string().min(1).max(255).refine(value => !/[\x00-\x1f\x7f]/.test(value));
16
+ const schema = z.object({
17
+ source_package_ids: z.array(packageId).min(1).max(20).optional(),
18
+ apim_proxy_names: z.array(proxyName).min(1).max(20).optional(),
19
+ target_node_name: nodeName
20
+ }).strict().refine(value => value.source_package_ids?.length || value.apim_proxy_names?.length, 'At least one source package or API proxy is required');
21
+ const MAX_MEMBERS = 500;
22
+ const MAX_PAGES = 20;
23
+ const safeText = (value, max = 255) => typeof value === 'string' && value.length <= max && !/[\x00-\x1f\x7f]/.test(value) ? value : null;
24
+ const safeFailure = error => ({ result: error?.status === 401 ? 'unauthenticated' : error?.status === 403 ? 'forbidden' : [404, 501].includes(error?.status) ? 'unavailable' : 'error',
25
+ ...(Number.isInteger(error?.status) ? { status: error.status } : {}), ...(error?.code === 'INTEGRATION_DEADLINE' ? { stop_reason: 'deadline' } : {}) });
26
+ const host = client => { try { return new URL(client?.origin).host; } catch { return null; } };
27
+
28
+ function nodeProjection(row) {
29
+ return { id: typeof row?.id === 'string' ? safeText(row.id) : Number.isSafeInteger(row?.id) ? row.id : null,
30
+ name: safeText(row?.name), upload_eligibility: 'unknown', import_eligibility: 'unknown' };
31
+ }
32
+
33
+ export async function readCtmsNodes(client, { top = 100, cursor, deadline } = {}) {
34
+ const pager = createCollectionPager(client, { root: '/v2', request: async (method, path, options) => {
35
+ const body = await client.request(method, `/v2${path}`, options);
36
+ if (!Array.isArray(body?.nodes)) throw new IntegrationError('cTMS nodes response is not the documented nodes collection');
37
+ return { value: body.nodes, '@odata.count': body.nodes.length };
38
+ } });
39
+ return pager.page({ path: '/nodes', top, cursor, mode: 'local', deadline, project: nodeProjection });
40
+ }
41
+
42
+ export function registerCtmsTools(register, client) {
43
+ register('ctms_list_nodes', 'List Cloud Transport Management nodes',
44
+ 'Read GET /v2/nodes with bounded local paging. Node identity/name access does not establish upload/import eligibility, route, Content Agent, entitlement or target readiness.',
45
+ z.object({ top: z.number().int().min(1).max(200).default(100), cursor: cursorSchema }).strict(), READ_ONLY_ANNOTATIONS,
46
+ args => readCtmsNodes(client, args));
47
+ }
48
+
49
+ async function enumerateGroup(client, package_id, type, remaining, deadline) {
50
+ if (remaining <= 0) return { items: [], returned: 0, total: null, complete: false, stop_reason: 'source_limit' };
51
+ const path = `/IntegrationPackages(${key(package_id)})/${CPI_PACKAGE_ARTIFACT_SETS[type]}`;
52
+ try {
53
+ const raw = await client.request('GET', `${CPI_BASE}${path}`, { unwrap: false, deadline });
54
+ // A finite explicit server count is evidence; a capped navigation with no count is not a complete package.
55
+ const counted = decodeCollection(raw).total !== null;
56
+ const pager = createCollectionPager(client, { root: CPI_BASE });
57
+ const items = [], seen = new Set();
58
+ let cursor, last;
59
+ for (let index = 0; index < MAX_PAGES; index++) {
60
+ last = await pager.page({ path, raw, cursor, top: Math.min(200, remaining - items.length), deadline,
61
+ sourceComplete: counted, consistency: 'best_effort', identity: 'Id',
62
+ project: row => ({ id: typeof row?.Id === 'string' && artifactId.test(row.Id) ? row.Id : null,
63
+ version: typeof row?.Version === 'string' && row.Version.length <= 100 && version.test(row.Version) ? row.Version : null }) });
64
+ for (const row of last.items) {
65
+ if (!row.id || !row.version || seen.has(row.id)) return { items, returned: items.length, total: last.total, complete: false, stop_reason: 'source_changed_or_invalid_identity' };
66
+ seen.add(row.id); items.push(row);
67
+ }
68
+ if (last.complete || !last.next_cursor || items.length >= remaining) break;
69
+ cursor = last.next_cursor;
70
+ }
71
+ const complete = last.complete && (last.total === null || last.total === items.length);
72
+ return { items, returned: items.length, total: last.total, complete,
73
+ stop_reason: complete ? 'end' : last.next_cursor ? 'source_limit' : last.stop_reason,
74
+ consistency: 'best_effort_not_transactional' };
75
+ } catch (error) { return { items: [], returned: 0, total: null, complete: false, stop_reason: error?.code === 'INTEGRATION_DEADLINE' ? 'deadline' : 'source_error', source_result: safeFailure(error) }; }
76
+ }
77
+
78
+ export async function transportReadiness(clients, input, { deadline = Date.now() + 60000, env = process.env, readiness = checkReleaseReadiness, snapshots = snapshotEvidenceForTargets } = {}) {
79
+ const args = schema.parse(input);
80
+ const blockers = new Set(), members = [], packages = [];
81
+ const selectedPackages = [...new Set(args.source_package_ids ?? [])];
82
+ const selectedProxies = [...new Set(args.apim_proxy_names ?? [])];
83
+ if (selectedPackages.length && !clients.cpi) blockers.add('cpi_unconfigured');
84
+ if (selectedProxies.length && !clients.apim) blockers.add('apim_unconfigured');
85
+ for (const id of selectedPackages) {
86
+ const groups = {};
87
+ if (clients.cpi) for (const type of Object.keys(CPI_PACKAGE_ARTIFACT_SETS)) {
88
+ const group = await enumerateGroup(clients.cpi, id, type, MAX_MEMBERS - members.length, deadline);
89
+ const { items, ...evidence } = group;
90
+ groups[type] = evidence;
91
+ for (const item of items) members.push({ package_id: id, type, ...item });
92
+ if (!group.complete) blockers.add('source_enumeration_incomplete');
93
+ }
94
+ packages.push({ id, groups, complete: Object.keys(groups).length === 4 && Object.values(groups).every(group => group.complete) });
95
+ }
96
+ if (!members.length && !selectedProxies.length) blockers.add('no_transportable_members_observed');
97
+ let node = null, nodes = { result: clients.ctms ? 'ok' : 'unconfigured', complete: false };
98
+ if (clients.ctms) try {
99
+ const matches = [];
100
+ let cursor;
101
+ for (let index = 0; index < MAX_PAGES; index++) {
102
+ const page = await readCtmsNodes(clients.ctms, { top: 200, cursor, deadline });
103
+ matches.push(...page.items.filter(item => item.name === args.target_node_name));
104
+ nodes.complete = page.complete; nodes.stop_reason = page.stop_reason;
105
+ if (page.complete || !page.next_cursor) break;
106
+ cursor = page.next_cursor;
107
+ }
108
+ if (matches.length === 1 && matches[0].id !== null) node = matches[0];
109
+ else blockers.add(matches.length > 1 ? 'target_node_ambiguous' : 'target_node_missing_or_identity_unverified');
110
+ } catch (error) { nodes = { ...safeFailure(error), complete: false }; }
111
+ if (!nodes.complete) blockers.add('target_node_inventory_unverified');
112
+
113
+ const identities = new Map();
114
+ for (const member of members) {
115
+ member.kind = member.type === 'iflows' ? 'cpi_iflow' : member.type;
116
+ if (member.type !== 'iflows') { member.readiness = 'native_review_required'; blockers.add('unchecked_non_iflow_members'); continue; }
117
+ const prior = identities.get(member.id);
118
+ if (prior) { blockers.add('duplicate_source_iflow_identity'); member.readiness = 'source_identity_ambiguous'; }
119
+ else identities.set(member.id, member);
120
+ }
121
+ const flows = [...identities.values()];
122
+ const checked = [];
123
+ for (let offset = 0; offset < flows.length; offset += 20) {
124
+ const batch = flows.slice(offset, offset + 20);
125
+ try {
126
+ const report = await readiness(clients, { cpi_iflow_ids: batch.map(row => row.id), expected_versions: Object.fromEntries(batch.map(row => [`cpi_iflow:${row.id}`, row.version])) }, { deadline });
127
+ for (const row of batch) {
128
+ const evidence = report.items.find(item => item.kind === 'cpi_iflow' && item.id === row.id);
129
+ if (evidence) { Object.assign(row, projectReadiness(evidence)); checked.push(evidence); }
130
+ else row.readiness = 'unchecked';
131
+ if (row.readiness !== 'ready') blockers.add('source_release_checks_not_ready');
132
+ }
133
+ } catch (error) {
134
+ blockers.add('source_release_checks_unavailable');
135
+ for (const row of batch) Object.assign(row, { readiness: 'unchecked', source_result: safeFailure(error) });
136
+ }
137
+ }
138
+ const proxies = [];
139
+ if (selectedProxies.length && clients.apim) try {
140
+ const report = await readiness(clients, { apim_proxy_names: selectedProxies }, { deadline });
141
+ for (const id of selectedProxies) {
142
+ const evidence = report.items.find(item => item.kind === 'apim_proxy' && item.id === id);
143
+ proxies.push(evidence ? { id, ...projectReadiness(evidence) } : { id, readiness: 'unchecked' });
144
+ if (evidence) checked.push(evidence);
145
+ if (evidence?.readiness !== 'ready') blockers.add('source_release_checks_not_ready');
146
+ }
147
+ } catch (error) { blockers.add('source_release_checks_unavailable'); proxies.push(...selectedProxies.map(id => ({ id, readiness: 'unchecked', source_result: safeFailure(error) }))); }
148
+
149
+ const snapshotEvidence = await snapshots(clients, checked.filter(item => item.state_sha256), { env, deadline });
150
+ for (const target of checked) {
151
+ if (!target.state_sha256 || !snapshotEvidence.items.some(snapshot => snapshot.targets.some(item => item.kind === target.kind && item.id === target.id && item.state_sha256 === target.state_sha256))) blockers.add('matching_verified_snapshot_required');
152
+ }
153
+ if (snapshotEvidence.result !== 'ok') blockers.add('snapshot_evidence_unreadable');
154
+ if (!snapshotEvidence.complete) blockers.add('snapshot_evidence_incomplete');
155
+ const ctms = capabilityCatalog({ summary: clients.summary }).ctms;
156
+ const result = {
157
+ readiness: blockers.size ? 'blocked' : 'needs_admin_confirmation',
158
+ evidence_scope: 'observed_source_versions_dependencies_validation_and_private_backups',
159
+ source_hosts: { cpi: host(clients.cpi), apim: host(clients.apim) }, ctms_host: host(clients.ctms),
160
+ target_node_name: args.target_node_name, target_node: node, node_inventory: nodes,
161
+ packages, enumeration_complete: packages.every(item => item.complete), members, api_proxies: proxies,
162
+ checked_iflows: flows.filter(item => item.readiness !== 'unchecked' && item.state_sha256).length,
163
+ observed_members: members.length, member_output_complete: true, snapshots: snapshotEvidence,
164
+ target_verification: 'requires_independent_target_bound_server',
165
+ native_prerequisites: ['Content Agent assembly in its supported format', 'Source/target destinations and export node', 'Transport route and node eligibility', 'Named request set and separate upload/import authorization'].map(name => ({ name, verification: 'needs_admin_confirmation' })),
166
+ blockers: [...blockers], native_handoff: ctms.native_handoff, source_links: ctms.source_links, contract_boundaries: ctms.contract_boundaries
167
+ };
168
+ while (Buffer.byteLength(JSON.stringify(result, null, 2)) > 94 * 1024 && result.members.length) {
169
+ result.members.pop(); result.member_output_complete = false;
170
+ }
171
+ if (!result.member_output_complete) { result.readiness = 'blocked'; result.blockers.push('member_output_limit'); }
172
+ return result;
173
+ }
174
+
175
+ function projectReadiness(item) {
176
+ return { readiness: item.readiness, checked_version: item.version, bundle_sha256: item.bundle_sha256, state_sha256: item.state_sha256,
177
+ blockers: item.blockers ?? [], reviews: item.reviews ?? [] };
178
+ }
179
+
180
+ export function registerIntegrationTransportTools(register, clients = {}, options = {}) {
181
+ register('integration_transport_readiness', 'Check native integration promotion prerequisites',
182
+ 'Read-only complete bounded package-group traversal, exact observed versions and batches of at most 20 iFlow release checks, APIM checks, private matching snapshots and named cTMS node evidence. Unknown/unchecked members block. Never assembles an MTAR, uploads, imports, resets or claims target readiness; native approvals and independent target-bound verification remain required.',
183
+ schema, READ_ONLY_ANNOTATIONS, args => transportReadiness(clients, args, options));
184
+ }
@@ -0,0 +1,26 @@
1
+ // Private per-client latest completed validation evidence; never initiate validation from readiness.
2
+ const TTL_MS = 15 * 60 * 1000;
3
+ const stores = new WeakMap();
4
+ const identity = (id, version) => JSON.stringify([id, version]);
5
+
6
+ export function rememberValidation(client, { id, resolved_version, state_sha256, bundle_sha256, passed, matching_state = true }, now = Date.now()) {
7
+ let entries = stores.get(client);
8
+ if (!entries) { entries = new Map(); stores.set(client, entries); }
9
+ for (const [key, entry] of entries) if (now - entry.completed_at_ms >= TTL_MS) entries.delete(key);
10
+ entries.set(identity(id, resolved_version), { state_sha256, bundle_sha256, passed, matching_state,
11
+ completed_at: new Date(now).toISOString(), completed_at_ms: now });
12
+ }
13
+
14
+ export function validationEvidence(client, { id, resolved_version, state_sha256 }, now = Date.now()) {
15
+ const entries = stores.get(client);
16
+ const key = identity(id, resolved_version);
17
+ const entry = entries?.get(key);
18
+ if (!entry) return { result: 'missing', passed: null };
19
+ if (now - entry.completed_at_ms >= TTL_MS) {
20
+ entries.delete(key);
21
+ return { result: 'expired', passed: null, completed_at: entry.completed_at };
22
+ }
23
+ if (!entry.matching_state || entry.state_sha256 !== state_sha256) return { result: 'state_changed', passed: null, completed_at: entry.completed_at };
24
+ return { result: entry.passed === true ? 'passed' : entry.passed === false ? 'failed' : 'unknown', passed: entry.passed,
25
+ completed_at: entry.completed_at, expires_at: new Date(entry.completed_at_ms + TTL_MS).toISOString() };
26
+ }
@@ -1,117 +1,198 @@
1
- // Online check of CPI/APIM credentials: sign in, then one read-only request per tool area so a
2
- // missing role shows up as a named area instead of a failing tool call later. Never writes.
3
- import { createServiceClient } from './integration-http.mjs';
1
+ // Read-only access evidence. Core access, optional capabilities and entitlement
2
+ // evidence are separate: an endpoint response cannot establish a subscription.
3
+ import { createServiceClient, odataKey } from './integration-http.mjs';
4
+ import { createOptionalClients, verifyOptionalClients } from './integration-optional-tools.mjs';
4
5
 
5
- const CSRF_PATHS = Object.freeze({ CPI: '/api/v1/', APIM: '/apiportal/api/1.0/Management.svc/' });
6
+ const CSRF_PATHS = Object.freeze({ CPI: '/api/v1/', APIM: '/apiportal/api/1.0/Management.svc/', DEVHUB: '/odata/1.0/data.svc/' });
6
7
  const APIM_BASE = '/apiportal/api/1.0/Management.svc';
8
+ const VERIFY_TIMEOUT_MS = 30_000;
7
9
 
8
10
  const PROBES = Object.freeze({
9
11
  CPI: [
10
12
  { area: 'Packages and iFlows', path: '/api/v1/IntegrationPackages', query: { $top: 1 }, core: true },
11
13
  { area: 'Deployed artifacts', path: '/api/v1/IntegrationRuntimeArtifacts', query: { $top: 1 } },
12
- { area: 'Message logs', path: '/api/v1/MessageProcessingLogs', query: { $top: 1 } },
14
+ { area: 'Message logs', path: '/api/v1/MessageProcessingLogs', query: { $top: 1, $select: 'MessageGuid' } },
15
+ { area: 'MPL counts', path: '/api/v1/MessageProcessingLogs/$count', count: true },
13
16
  { area: 'Security material', path: '/api/v1/UserCredentials' },
17
+ { area: 'OAuth credential metadata', path: '/api/v1/OAuth2ClientCredentials' },
18
+ { area: 'Keystore validity', path: '/api/v1/KeystoreEntries', query: { keystoreName: 'system' }, validity: true },
14
19
  { area: 'Number ranges', path: '/api/v1/NumberRanges' },
15
20
  { area: 'Partner Directory', path: '/api/v1/StringParameters', query: { $top: 1 } },
16
21
  { area: 'Data stores', path: '/api/v1/DataStores' },
17
- { area: 'JMS queues', path: "/api/v1/JmsBrokers('Broker1')" }
22
+ { area: 'JMS queues', path: "/api/v1/JmsBrokers('Broker1')" },
23
+ { area: 'Queue metadata', path: '/api/v1/Queues' },
24
+ { area: 'Variables', path: '/api/v1/Variables' }
18
25
  ],
19
26
  APIM: [
20
27
  { area: 'API proxies', path: `${APIM_BASE}/APIProxies`, query: { $top: 1, $select: 'name' }, core: true },
21
28
  { area: 'API providers', path: `${APIM_BASE}/APIProviders`, query: { $top: 1, $select: 'name' } },
22
29
  { area: 'API products', path: `${APIM_BASE}/APIProducts`, query: { $top: 1, $select: 'name' } },
23
30
  { area: 'Key value maps', path: `${APIM_BASE}/KeyMapEntries`, query: { $top: 1, $select: 'name' } },
24
- { area: 'Virtual hosts', path: `${APIM_BASE}/VirtualHosts` }
31
+ { area: 'Virtual hosts', path: `${APIM_BASE}/VirtualHosts` },
32
+ { area: 'API analytics', path: '/api/1.0/AnalyticsService/ResultsUTC', query: { $top: 1, $select: 'CreatedTime,CallCount' } }
33
+ ],
34
+ DEVHUB: [
35
+ { area: 'Registered developers', path: '/api/1.0/registrations', query: { type: 'registered' }, core: true }
25
36
  ]
26
37
  });
27
38
 
28
- // Statuses accepted as answers so they can be classified instead of thrown.
29
- const OBSERVED = [200, 204, 400, 401, 403, 404, 405, 500, 501, 502, 503, 504];
39
+ // Statuses accepted as answers so they can be classified without exposing bodies.
40
+ const OBSERVED = [200, 204, 400, 401, 403, 404, 405, 429, 500, 501, 502, 503, 504];
30
41
 
31
42
  function classify(status) {
32
43
  if (status >= 200 && status < 300) return { result: 'ok', detail: 'readable' };
33
- if (status === 401 || status === 403) return { result: 'forbidden', detail: `HTTP ${status}: the service key lacks the role for this area` };
34
- if (status === 404 || status === 501) return { result: 'unavailable', detail: `HTTP ${status}: not available on this tenant or plan` };
44
+ if (status === 401) return { result: 'unauthenticated', detail: 'HTTP 401: authentication was not accepted after recovery' };
45
+ if (status === 403) return { result: 'forbidden', detail: 'HTTP 403: access denied; the cause or missing permission is not established' };
46
+ if (status === 404 || status === 501) return { result: 'unavailable', detail: `HTTP ${status}: endpoint unavailable; activation and entitlement remain unknown` };
35
47
  return { result: 'error', detail: `HTTP ${status}` };
36
48
  }
37
49
 
38
- /** OAuth clients for the resolved CPI/APIM config (null for an unconfigured side). */
50
+ function rows(text) {
51
+ try {
52
+ const parsed = JSON.parse(text);
53
+ const collection = parsed?.d?.results ?? parsed?.value ?? parsed?.d ?? parsed;
54
+ return Array.isArray(collection) ? collection : [];
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ function source(probe, status, result, extra = {}) {
61
+ return { method: 'GET', path: probe.path, status, result, ...extra };
62
+ }
63
+
64
+ function failed(probe, error) {
65
+ const status = Number.isInteger(error?.status) ? error.status : null;
66
+ const tokenFailure = String(error?.phase ?? '').startsWith('token');
67
+ const classification = error?.stop_reason === 'deadline'
68
+ ? { result: 'error', detail: 'Verification deadline exceeded', stop_reason: 'deadline' }
69
+ : status === null ? { result: 'error', detail: 'Request failed without an HTTP response' }
70
+ : status >= 200 && status < 300 ? { result: 'error', detail: `HTTP ${status}: response processing failed` } : classify(status);
71
+ // Token HTTP errors are useful setup evidence, but raw response/error bodies are not.
72
+ if (tokenFailure && status !== null && error?.stop_reason !== 'deadline') classification.detail = `Token request failed (HTTP ${status})`;
73
+ return {
74
+ area: probe.area, status, ...classification,
75
+ source: source(probe, status, classification.result, {
76
+ ...(tokenFailure ? { phase: 'token' } : {}),
77
+ ...(error?.stop_reason === 'deadline' ? { stop_reason: 'deadline', deadline: error.deadline } : {})
78
+ })
79
+ };
80
+ }
81
+
82
+ /** Core and optional clients are pinned to this runtime's resolved configuration. */
39
83
  export function createIntegrationClients(config, { env = process.env, fetchImpl } = {}) {
40
84
  const clientFor = (label, side) => (side
41
85
  ? createServiceClient({
42
- label,
43
- url: side.url,
44
- tokenUrl: side.tokenUrl,
45
- clientId: side.clientId,
46
- clientSecret: side.clientSecret,
47
- csrfPath: CSRF_PATHS[label],
48
- env,
86
+ label, url: side.url, tokenUrl: side.tokenUrl, clientId: side.clientId, clientSecret: side.clientSecret,
87
+ csrfPath: CSRF_PATHS[label], exposeErrorDetails: label !== 'DEVHUB', env,
49
88
  ...(fetchImpl ? { fetchImpl } : {})
50
89
  })
51
90
  : null);
52
- return { cpi: clientFor('CPI', config?.cpi), apim: clientFor('APIM', config?.apim) };
91
+ return { cpi: clientFor('CPI', config?.cpi), apim: clientFor('APIM', config?.apim), devhub: clientFor('DEVHUB', config?.devhub), ...createOptionalClients(config, { env, fetchImpl }) };
53
92
  }
54
93
 
55
- // Policies are attached by exporting the proxy bundle (Transport.svc), editing it and re-importing it.
56
- // The export of an existing proxy proves the read half without writing; no proxy means nothing to test.
57
- async function policyCheck(client, coreText) {
58
- const area = 'Policies (proxy bundle export)';
59
- let name;
94
+ async function runProbe(client, probe, deadline) {
60
95
  try {
61
- const parsed = JSON.parse(coreText);
62
- name = (parsed?.d?.results ?? parsed?.d ?? parsed?.value ?? [])[0]?.name;
63
- } catch {
64
- // Unparseable list: reported as unavailable below.
96
+ const { status, data } = await client.request('GET', probe.path, {
97
+ query: probe.query, expect: OBSERVED, withStatus: true,
98
+ responseType: 'text', accept: probe.count ? 'text/plain' : 'application/json', deadline
99
+ });
100
+ let classification = classify(status);
101
+ if (classification.result === 'ok' && probe.count && !/^\d+$/.test(String(data).trim())) {
102
+ classification = { result: 'error', detail: 'Count endpoint returned invalid count text' };
103
+ } else if (classification.result === 'ok' && probe.count && !Number.isSafeInteger(Number(data))) {
104
+ classification = { result: 'error', detail: 'Count endpoint returned a count outside the safe integer range' };
105
+ }
106
+ if (classification.result === 'ok' && probe.validity) {
107
+ const entries = rows(data);
108
+ if (!entries.some(entry => entry?.ValidNotBefore !== undefined && entry?.ValidNotAfter !== undefined)) {
109
+ classification = { result: 'untested', detail: 'No certificate with validity fields exists to test validity metadata' };
110
+ }
111
+ }
112
+ return { area: probe.area, status, ...classification, source: source(probe, status, classification.result), text: data };
113
+ } catch (error) {
114
+ return failed(probe, error);
65
115
  }
66
- if (typeof name !== 'string' || !name) return { area, status: null, result: 'unavailable', detail: 'no API proxy exists yet to test the export with' };
116
+ }
117
+
118
+ async function policyCheck(client, coreText, deadline) {
119
+ const probe = { area: 'Policies (proxy bundle export)', path: '/apiportal/api/1.0/Transport.svc/APIProxies' };
120
+ const name = rows(coreText)[0]?.name;
121
+ if (typeof name !== 'string' || !name) return {
122
+ area: probe.area, status: null, result: 'untested', detail: 'No API proxy exists to test bundle export',
123
+ source: source(probe, null, 'untested')
124
+ };
67
125
  try {
68
- const { status, data } = await client.request('GET', '/apiportal/api/1.0/Transport.svc/APIProxies', {
69
- query: { name }, expect: OBSERVED, withStatus: true, responseType: 'bytes', accept: 'application/zip, application/octet-stream'
126
+ const { status, data } = await client.request('GET', probe.path, {
127
+ query: { name }, expect: OBSERVED, withStatus: true, responseType: 'bytes',
128
+ accept: 'application/zip, application/octet-stream', deadline
70
129
  });
71
130
  const zip = data instanceof Uint8Array && data[0] === 0x50 && data[1] === 0x4b;
72
- if (status >= 200 && status < 300 && !zip) return { area, status, result: 'error', detail: `HTTP ${status} but the export is not a zip bundle` };
73
- return { area, status, ...classify(status) };
131
+ const classification = status >= 200 && status < 300 && !zip
132
+ ? { result: 'error', detail: `HTTP ${status} but the export is not a zip bundle` } : classify(status);
133
+ return { area: probe.area, status, ...classification, source: source(probe, status, classification.result) };
74
134
  } catch (error) {
75
- return { area, status: null, result: 'error', detail: String(error?.message || 'request failed').slice(0, 300) };
135
+ return failed(probe, error);
76
136
  }
77
137
  }
78
138
 
79
- async function verifySide(label, client) {
80
- if (!client) return { configured: false };
81
- const probes = PROBES[label];
82
- const run = async probe => {
83
- try {
84
- const { status, data } = await client.request('GET', probe.path, { query: probe.query, expect: OBSERVED, withStatus: true, responseType: 'text', accept: 'application/json' });
85
- return { area: probe.area, status, ...classify(status), text: data };
86
- } catch (error) {
87
- return { area: probe.area, status: null, result: 'error', detail: String(error?.message || 'request failed').slice(0, 300) };
88
- }
139
+ async function runsCheck(client, messageCheck, deadline) {
140
+ const probe = { area: 'MPL runs', path: '/api/v1/MessageProcessingLogs' };
141
+ if (messageCheck.result !== 'ok') return {
142
+ area: probe.area, status: null, result: 'untested', detail: 'Message-log identity lookup did not succeed',
143
+ source: source(probe, null, 'untested', { prerequisite_result: messageCheck.result })
144
+ };
145
+ const guid = rows(messageCheck.text)[0]?.MessageGuid;
146
+ if (typeof guid !== 'string' || !guid) return {
147
+ area: probe.area, status: null, result: 'untested', detail: 'No message exists to test run metadata',
148
+ source: source(probe, null, 'untested')
89
149
  };
90
- const { text: coreText, ...core } = await run(probes[0]);
91
- // The core probe also fetches the token: a thrown request is a token or network failure, reported once.
92
- if (core.status === null) {
93
- return { configured: true, signed_in: false, ok: false, error: core.detail, checks: [] };
150
+ return runProbe(client, { ...probe, path: `/api/v1/MessageProcessingLogs(${encodeURIComponent(odataKey(guid))})/Runs` }, deadline);
151
+ }
152
+
153
+ async function verifySide(label, client, deadline) {
154
+ if (!client) return { configured: false, entitlement: 'unknown' };
155
+ const probes = PROBES[label];
156
+ const core = await runProbe(client, probes[0], deadline);
157
+ const { text: coreText, ...coreCheck } = core;
158
+ const signedIn = core.status !== null && core.result !== 'unauthenticated' && core.source.phase !== 'token';
159
+ if (!signedIn) {
160
+ return {
161
+ configured: true, signed_in: false, ok: false, result: core.result, entitlement: 'unknown',
162
+ error: core.source.phase === 'token' && core.status !== null ? `${label} token request failed (HTTP ${core.status})` : core.detail,
163
+ checks: [coreCheck]
164
+ };
94
165
  }
95
- const others = probes.slice(1).map(run);
96
- if (label === 'APIM' && core.result === 'ok') others.push(policyCheck(client, coreText));
97
- const checks = [core, ...(await Promise.all(others)).map(({ text, ...check }) => check)];
98
- return { configured: true, signed_in: true, ok: core.result === 'ok', checks };
166
+ const results = await Promise.all(probes.slice(1).map(probe => runProbe(client, probe, deadline)));
167
+ if (label === 'APIM' && core.result === 'ok') results.push(await policyCheck(client, coreText, deadline));
168
+ if (label === 'CPI') results.push(await runsCheck(client, results.find(check => check.area === 'Message logs'), deadline));
169
+ const checks = [coreCheck, ...results.map(({ text, ...check }) => check)];
170
+ return { configured: true, signed_in: true, ok: core.result === 'ok', result: core.result, entitlement: 'unknown', checks };
99
171
  }
100
172
 
101
- /** Verify the configured sides online; `ok` means every configured side signed in and passed its core read. */
102
- export async function verifyIntegrationClients({ cpi, apim } = {}) {
103
- const [cpiResult, apimResult] = await Promise.all([verifySide('CPI', cpi), verifySide('APIM', apim)]);
104
- const sides = [cpiResult, apimResult].filter(side => side.configured);
105
- return { ok: sides.length > 0 && sides.every(side => side.ok), cpi: cpiResult, apim: apimResult };
173
+ /** `ok` means every configured core service passed its core read, not whole-lifecycle readiness. */
174
+ export async function verifyIntegrationClients(clients = {}) {
175
+ const { cpi, apim, devhub } = clients;
176
+ const deadline = Date.now() + VERIFY_TIMEOUT_MS;
177
+ const [cpiResult, apimResult, devhubResult, optional] = await Promise.all([
178
+ verifySide('CPI', cpi, deadline), verifySide('APIM', apim, deadline), verifySide('DEVHUB', devhub, deadline),
179
+ verifyOptionalClients(clients, { deadline })
180
+ ]);
181
+ const sides = [cpiResult, apimResult, devhubResult].filter(side => side.configured);
182
+ return {
183
+ ok: sides.length > 0 && sides.every(side => side.ok),
184
+ ok_scope: 'configured_core_access', entitlement: 'unknown',
185
+ cpi: cpiResult, apim: apimResult, devhub: devhubResult, ...optional
186
+ };
106
187
  }
107
188
 
108
- /** Human-readable lines for one verified side (no secrets: only host, area names and HTTP statuses). */
189
+ /** Human-readable access lines; no response bodies, object values or personal details. */
109
190
  export function verificationLines(label, host, side) {
110
191
  if (!side?.configured) return [];
111
- if (!side.signed_in) return [`✗ ${label} ${host}: sign-in failed — ${side.error}`];
112
- const lines = [side.ok ? `✓ ${label} ${host}: signed in` : `✗ ${label} ${host}: signed in, but ${side.checks[0]?.area ?? 'the core area'} is not readable with this key`];
192
+ if (!side.signed_in) return [`✗ ${label} ${host}: sign-in not verified — ${side.error}`];
193
+ const lines = [side.ok ? `✓ ${label} ${host}: core access verified` : `✗ ${label} ${host}: ${side.checks[0]?.area ?? 'the core area'} is not readable with this key`];
113
194
  for (const check of side.checks) {
114
- const mark = check.result === 'ok' ? '✓' : (check.result === 'unavailable' ? '–' : '✗');
195
+ const mark = check.result === 'ok' ? '✓' : (['unavailable', 'untested'].includes(check.result) ? '–' : '✗');
115
196
  lines.push(` ${mark} ${check.area}${check.result === 'ok' ? '' : ` — ${check.detail}`}`);
116
197
  }
117
198
  return lines;
@@ -0,0 +1,143 @@
1
+ // Open Connectors platform metadata and the one documented instance enabled-state action.
2
+ import { randomUUID } from 'node:crypto';
3
+ import { z } from 'zod';
4
+ import { createStaticServiceClient, IntegrationError } from './integration-http.mjs';
5
+ import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
6
+ import { READ_ONLY_ANNOTATIONS, OVERWRITE_ANNOTATIONS, requireConfirmation, top } from './integration-common.mjs';
7
+
8
+ const ROOT = '/elements/api-v2';
9
+ const SCOPE = 'open_connectors.instances.v1';
10
+ const TTL = 15 * 60 * 1000;
11
+ const SOURCE_LIMIT = 2 * 1024 * 1024;
12
+ const credentials = new WeakMap();
13
+ const id = z.union([z.number().int().positive().max(Number.MAX_SAFE_INTEGER), z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/).refine(value => value !== 'enabled')]);
14
+ const text = z.string().max(512).regex(/^[^\x00-\x1f\x7f]*$/);
15
+ const field = value => text.safeParse(value).success;
16
+ const secret = value => typeof value === 'string' && value.length > 0 && value.length <= 16384 && /^[\x21-\x7e]+$/.test(value) && !value.includes(',');
17
+ const invalid = message => { throw new IntegrationError(`Open Connectors ${message}`); };
18
+
19
+ /** Config has already passed the host optional-config parser; keep credentials out of client properties. */
20
+ export function createOpenConnectorsClient({ url, userSecret, organizationSecret }, { env, fetchImpl } = {}) {
21
+ if (!secret(userSecret) || !secret(organizationSecret)) invalid('authorization configuration is invalid');
22
+ const base = new URL(url);
23
+ if (!base.pathname.endsWith(ROOT) || base.username || base.password || base.search || base.hash) invalid('service root is invalid');
24
+ const authorization = `User ${userSecret}, Organization ${organizationSecret}`;
25
+ const options = { label: 'Open Connectors', url, env, fetchImpl };
26
+ const client = createStaticServiceClient({ ...options, authorization: () => authorization });
27
+ credentials.set(client, Object.freeze({ root: base.pathname, action: (token, method) => createStaticServiceClient({ ...options, authorization: request => {
28
+ if (request.method !== method || request.path !== `${base.pathname}/instances/enabled`) invalid('enabled-state authorization scope is invalid');
29
+ return `${authorization}, Element ${token}`;
30
+ } }) }));
31
+ return client;
32
+ }
33
+
34
+ function project(row) {
35
+ if (!row || typeof row !== 'object' || Array.isArray(row) || !id.safeParse(row.id).success
36
+ || row.name !== undefined && !field(row.name)) invalid('instance metadata has an invalid identity or schema');
37
+ const result = { id: row.id, ...(row.name !== undefined ? { name: row.name } : {}) };
38
+ if (row.element !== undefined && row.element !== null) {
39
+ const value = row.element;
40
+ if (typeof value !== 'object' || Array.isArray(value) || value.id !== undefined && !id.safeParse(value.id).success
41
+ || ['key', 'name'].some(key => value[key] !== undefined && !field(value[key]))) invalid('element metadata has an invalid schema');
42
+ result.element = Object.fromEntries(['id', 'key', 'name'].filter(key => value[key] !== undefined).map(key => [key, value[key]]));
43
+ }
44
+ return result;
45
+ }
46
+
47
+ async function named(client, instanceId, deadline) {
48
+ const row = await client.request('GET', `/instances/${encodeURIComponent(instanceId)}`, { unwrap: false, deadline });
49
+ const safe = project(row);
50
+ if (String(safe.id) !== String(instanceId)) invalid('named instance identity does not match the request');
51
+ return { row, safe };
52
+ }
53
+
54
+ function checked(schema, handler) {
55
+ const input = z.object(schema).strict();
56
+ return args => {
57
+ const parsed = input.safeParse(args);
58
+ if (!parsed.success) invalid('tool input is invalid');
59
+ return handler(parsed.data);
60
+ };
61
+ }
62
+
63
+ export function registerOpenConnectorsTools(register, client) {
64
+ const root = credentials.get(client)?.root ?? ROOT;
65
+ const guard = createCollectionPager(client, { root });
66
+ // Raw server tokens never enter a signed (but readable) cursor. Only random private lookup IDs do.
67
+ const listings = new Map();
68
+ async function instances({ top: size, cursor }) {
69
+ const now = Date.now();
70
+ for (const [key, value] of listings) if (value.expires <= now) listings.delete(key);
71
+ const context = cursor ? guard.cursorContext(cursor, { scope: SCOPE }) : randomUUID();
72
+ let listing = listings.get(context);
73
+ if (cursor && !listing) invalid('collection cursor is expired; restart the listing');
74
+ if (!listing) {
75
+ if (listings.size >= 16) listings.delete(listings.keys().next().value);
76
+ listing = { expires: now + TTL, pages: new Map([['first', { token: null, prior: [] }]]), bytes: 0 };
77
+ listings.set(context, listing);
78
+ }
79
+ let observed;
80
+ const pager = createCollectionPager(client, { root, request: async (method, path, options) => {
81
+ if (method !== 'GET' || path !== '/instances' || Object.keys(options.query).some(key => !['$top', '$skiptoken', '$skip'].includes(key))) invalid('collection request scope is invalid');
82
+ const key = options.query.$skiptoken ?? 'first';
83
+ const page = listing.pages.get(key);
84
+ if (!page || options.query.$skip) invalid('collection continuation is invalid');
85
+ if (!page.rows) {
86
+ if (page.end) page.rows = [];
87
+ else {
88
+ const source = await client.request('GET', '/instances', { query: { pageSize: 200, ...(page.token === null ? {} : { nextPage: page.token }) }, unwrap: false, responseHeaders: ['elements-next-page-token', 'nextpage'], deadline: options.deadline });
89
+ if (!Array.isArray(source.data) || source.data.length > 200) invalid('instance collection has an invalid schema or exceeds its page bound');
90
+ const rows = source.data.map(project);
91
+ if (new Set(rows.map(row => String(row.id))).size !== rows.length) invalid('instance collection contains duplicate identities');
92
+ const bytes = Buffer.byteLength(JSON.stringify(rows));
93
+ if (listing.bytes + bytes > SOURCE_LIMIT) invalid('instance collection exceeds the safe size limit');
94
+ const tokens = [source.responseHeaders['elements-next-page-token'], source.responseHeaders.nextpage].filter(value => value !== undefined && value !== '');
95
+ if (tokens.some(value => typeof value !== 'string' || value.length > 4096 || /[\x00-\x20\x7f]/.test(value)) || new Set(tokens).size > 1) invalid('pagination metadata is invalid');
96
+ const token = tokens[0] ?? null;
97
+ if (token !== null && (token === page.token || page.prior.includes(token))) invalid('pagination metadata repeats a continuation');
98
+ if (token !== null && rows.length === 0) invalid('pagination metadata continues an empty page');
99
+ if (listing.pages.size >= 256) invalid('instance collection exceeds the safe size limit');
100
+ page.rows = rows;
101
+ page.terminal = token === null;
102
+ listing.bytes += bytes;
103
+ // A private end marker lets the shared pager prove exhaustion without inventing a count.
104
+ if (rows.length) {
105
+ page.next = randomUUID();
106
+ listing.pages.set(page.next, { token, prior: token === null ? [] : [...page.prior, token], end: token === null });
107
+ }
108
+ }
109
+ }
110
+ observed = page;
111
+ return { d: { results: page.rows, ...(page.next ? { __next: `${root}/instances?$skiptoken=${page.next}` } : {}) } };
112
+ } });
113
+ const result = await pager.page({ path: '/instances', top: size, cursor, scope: SCOPE, context, fallback: false, deadline: now + 30000 });
114
+ // Absence of the documented token establishes the end; counts remain unknown, never page length.
115
+ if (observed?.terminal && result.stop_reason === 'more' && result.items.length
116
+ && String(result.items.at(-1).id) === String(observed.rows.at(-1)?.id)) return { ...result, complete: true, next_cursor: null, stop_reason: 'end' };
117
+ return result;
118
+ }
119
+ const listInput = { top: top(200, 100), cursor: cursorSchema };
120
+ register('open_connectors_list_instances', 'List Open Connectors instances',
121
+ 'Read bounded instance identities and element metadata only. Tokens, configuration and provider data are withheld. Opaque runtime-bound cursors resume documented nextPage pagination; source totals remain unknown.',
122
+ listInput, READ_ONLY_ANNOTATIONS, checked(listInput, instances));
123
+ const getInput = { instance_id: id };
124
+ register('open_connectors_get_instance', 'Get Open Connectors instance metadata',
125
+ 'Read one exact instance identity and element id/key/name using platform User/Organization authorization. No token, configuration, provider data or provider-health assertion is returned.',
126
+ getInput, READ_ONLY_ANNOTATIONS, checked(getInput, async ({ instance_id }) => (await named(client, instance_id, Date.now() + 30000)).safe));
127
+ const actionInput = { instance_id: id, enabled: z.boolean(), confirmation: z.string().max(256) };
128
+ register('open_connectors_set_instance_enabled', 'Set Open Connectors instance enabled state',
129
+ 'Require exactly ENABLE OPEN CONNECTOR INSTANCE <id> or DISABLE OPEN CONNECTOR INSTANCE <id> before any request. Privately read a fresh instance token, then bodyless PUT (enable) or DELETE (disable) /instances/enabled. DELETE disables, never deletes the instance. HTTP acceptance does not verify effective state or provider health; provider OAuth/re-authentication remains native.',
130
+ actionInput, OVERWRITE_ANNOTATIONS, checked(actionInput, async ({ instance_id, enabled, confirmation }) => {
131
+ requireConfirmation(confirmation, `${enabled ? 'ENABLE' : 'DISABLE'} OPEN CONNECTOR INSTANCE ${instance_id}`);
132
+ const state = credentials.get(client);
133
+ if (!state) invalid('enabled-state credentials are not configured');
134
+ const deadline = Date.now() + 30000;
135
+ const { row } = await named(client, instance_id, deadline);
136
+ if (!secret(row.token)) invalid('named instance has no usable private token');
137
+ const method = enabled ? 'PUT' : 'DELETE';
138
+ const action = state.action(row.token, method);
139
+ const response = await action.request(method, '/instances/enabled', { expect: Array.from({ length: 100 }, (_, index) => 200 + index), responseType: 'none', withStatus: true, deadline });
140
+ return { instance_id, requested_enabled: enabled, accepted: true, http_status: response.status,
141
+ effective_state: 'unverified', provider_health: 'unverified' };
142
+ }));
143
+ }