sap-ai-dev-toolkit 0.9.9917 → 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 +73 -14
  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 +52 -60
  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,157 @@
1
+ // Private APIM content reads shared by authoring, inspection and release evidence.
2
+ import { entity, key, list, sleep, withoutSecrets } from './integration-common.mjs';
3
+ import { bundleDocumentation } from './apim-bundle.mjs';
4
+ import { bundleDigest, readBundle, strFromU8 } from './iflow-bundle.mjs';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import { IntegrationError } from './integration-http.mjs';
7
+
8
+ export const APIM_BASE = '/apiportal/api/1.0/Management.svc';
9
+ export const APIM_TRANSPORT = '/apiportal/api/1.0/Transport.svc/APIProxies';
10
+
11
+ /** Resolve the published URL or the configured virtual host/base path. Callers enforce their surface's URL policy. */
12
+ export function apiProxyRuntimeUrl(proxy, defaultHost) {
13
+ const candidates = [proxy?.publishUrl, ...list(proxy?.deploymentInfo).map(entry => entry?.publishUrl), proxy?.deploymentInfo?.publishUrl];
14
+ const published = candidates.find(value => typeof value === 'string' && /^https?:\/\/\S+$/.test(value));
15
+ if (published) return published;
16
+ const endpoint = list(proxy?.proxyEndPoints)[0];
17
+ const host = list(endpoint?.virtualhosts)[0] ?? defaultHost;
18
+ if (!endpoint || !host?.virtual_host) return null;
19
+ const port = Number(host.virtual_port);
20
+ const portText = !port || port === 443 || port === 80 ? '' : `:${port}`;
21
+ const path = endpoint.base_path ?? '';
22
+ return `${host.isSSL ? 'https' : 'http'}://${host.virtual_host}${portText}${path.startsWith('/') ? path : `/${path}`}`;
23
+ }
24
+
25
+ export async function readApiProxy(client, name, { deadline } = {}) {
26
+ const { status, data } = await client.request('GET', `${APIM_BASE}/APIProxies(${key(name)})`, {
27
+ query: { $expand: 'proxyEndPoints,targetEndPoints,proxyEndPoints/virtualhosts,deploymentInfo' },
28
+ expect: [200, 404], withStatus: true, deadline
29
+ });
30
+ if (status === 404) return null;
31
+ let defaultHost = null;
32
+ if (!list(list(data?.proxyEndPoints)[0]?.virtualhosts).length) {
33
+ defaultHost = list(await client.request('GET', `${APIM_BASE}/VirtualHosts`, {
34
+ query: { $filter: 'isDefault eq true' }, deadline
35
+ }))[0] ?? null;
36
+ }
37
+ return { ...withoutSecrets(entity(data)), runtime_url: apiProxyRuntimeUrl(data, defaultHost) };
38
+ }
39
+
40
+ export async function exportApiProxyArchive(client, name, { deadline } = {}) {
41
+ const { status, data } = await client.request('GET', APIM_TRANSPORT, {
42
+ query: { name }, accept: 'application/zip, application/octet-stream', responseType: 'bytes',
43
+ expect: [200, 404], withStatus: true, deadline
44
+ });
45
+ return status === 404 ? null : data;
46
+ }
47
+
48
+ export async function exportApiProxyBundle(client, name, options) {
49
+ const bytes = await exportApiProxyArchive(client, name, options);
50
+ if (bytes === null) return null;
51
+ try { return readBundle(bytes); }
52
+ catch { throw new IntegrationError(`API proxy ${name} export is not a readable zip archive`); }
53
+ }
54
+
55
+ // Only the observed portal documentation transformations are allowed. Unknown resources,
56
+ // policies, endpoint XML and binary files must remain byte-for-byte equal.
57
+ function normalizeDocumentationText(document) {
58
+ const opaque = new Set(['example', 'examples', 'default', 'enum', 'const']);
59
+ const entities = { lt: '<', gt: '>', amp: '&' };
60
+ const visit = value => {
61
+ if (!value || typeof value !== 'object') return;
62
+ for (const [name, child] of Object.entries(value)) {
63
+ if (name === 'description' && typeof child === 'string') value[name] = child.replace(/&(lt|gt|amp);/g, (_, entity) => entities[entity]).replace(/\s+/g, ' ').trim();
64
+ else if (!opaque.has(name) && !name.startsWith('x-')) visit(child);
65
+ }
66
+ };
67
+ visit(document);
68
+ return document;
69
+ }
70
+
71
+ function contentMatches(expected, actual, name, proxy) {
72
+ if (expected.size !== actual.size) return { verified: false, normalized: false };
73
+ let documentation = null;
74
+ let normalized = false;
75
+ for (const [path, bytes] of expected) {
76
+ const observed = actual.get(path);
77
+ if (!observed) return { verified: false, normalized: false };
78
+ if (Buffer.from(bytes).equals(Buffer.from(observed))) continue;
79
+ // Resolve the registration with the existing structural scanner, not a suffix guess.
80
+ try {
81
+ documentation ??= bundleDocumentation(expected, name);
82
+ if (path !== documentation.path || bundleDocumentation(actual, name).path !== path) return { verified: false, normalized: false };
83
+ const submitted = JSON.parse(strFromU8(bytes));
84
+ const received = JSON.parse(strFromU8(observed));
85
+ if (isDeepStrictEqual(submitted, received)) continue;
86
+ const runtime = new URL(proxy.runtime_url);
87
+ const endpoint = `${runtime.protocol}//${runtime.hostname}:${runtime.port || '443'}${runtime.pathname}`;
88
+ if (typeof proxy.title === 'string') submitted.info.title = proxy.title;
89
+ if (typeof proxy.description === 'string') submitted.info.description = proxy.description;
90
+ submitted.info['x-targetEndpoint'] = endpoint;
91
+ if (submitted.openapi) submitted.servers = [{ url: endpoint }];
92
+ if (!isDeepStrictEqual(normalizeDocumentationText(received), normalizeDocumentationText(submitted))) return { verified: false, normalized: false };
93
+ normalized = true;
94
+ } catch {
95
+ return { verified: false, normalized: false };
96
+ }
97
+ }
98
+ return { verified: true, normalized };
99
+ }
100
+
101
+ /** Import once; acceptance, complete content read-back and runtime evidence are separate facts. */
102
+ export async function importApiProxyBundle(client, name, bytes, { readBackDelayMs = 5000, deadline = Date.now() + 20_000 } = {}) {
103
+ const intended = readBundle(bytes); // Reject malformed/unsafe archives before the mutation.
104
+ const intendedDigest = bundleDigest(intended);
105
+ const { status } = await client.request('POST', APIM_TRANSPORT, {
106
+ body: Buffer.from(bytes).toString('base64'), contentType: 'application/octet-stream',
107
+ accept: 'application/zip', responseType: 'none', expect: [200, 201], withStatus: true, deadline
108
+ });
109
+ const result = {
110
+ name, accepted: true, imported: true, http_status: status, outcome: 'unverified',
111
+ content_verified: false, read_back_verified: false, read_back_normalized: false,
112
+ runtime_verified: false, runtime_url: null, state: null,
113
+ intended_bundle_sha256: intendedDigest, observed_bundle_sha256: null,
114
+ effects: { draft_created_or_replaced: true, new_revision_imported: true, deployment_requested: true },
115
+ read_back_attempts: 0, stop_reason: 'source_limit',
116
+ note: 'Import accepted; runtime freshness/revision identity is unverified. Do not repeat the import blindly.'
117
+ };
118
+ for (let attempt = 0; attempt < 4; attempt += 1) {
119
+ if (attempt) {
120
+ if (Date.now() + readBackDelayMs >= deadline) { result.stop_reason = 'deadline'; break; }
121
+ await sleep(readBackDelayMs);
122
+ }
123
+ if (Date.now() >= deadline) { result.stop_reason = 'deadline'; break; }
124
+ result.read_back_attempts += 1;
125
+ let proxy = null;
126
+ try {
127
+ proxy = await readApiProxy(client, name, { deadline });
128
+ result.state = typeof proxy?.state === 'string' ? proxy.state.slice(0, 100) : null;
129
+ if (proxy?.runtime_url) {
130
+ const url = new URL(proxy.runtime_url);
131
+ if (!url.username && !url.password && ['https:', 'http:'].includes(url.protocol)) {
132
+ url.search = ''; url.hash = '';
133
+ if (url.href.length <= 2048) result.runtime_url = url.href;
134
+ }
135
+ }
136
+ } catch {
137
+ result.runtime_read_back_error = 'Proxy metadata read-back failed; response withheld.';
138
+ }
139
+ try {
140
+ const observed = await exportApiProxyBundle(client, name, { deadline });
141
+ if (!observed) { result.content_read_back_error = 'Imported content is not yet available.'; continue; }
142
+ result.observed_bundle_sha256 = bundleDigest(observed);
143
+ const comparison = contentMatches(intended, observed, name, proxy);
144
+ result.content_verified = result.read_back_verified = comparison.verified;
145
+ result.read_back_normalized = comparison.normalized;
146
+ if (comparison.verified) {
147
+ delete result.content_read_back_error;
148
+ result.stop_reason = 'end';
149
+ break;
150
+ }
151
+ result.content_read_back_error = 'Imported content read-back differs; do not repeat the import blindly.';
152
+ } catch {
153
+ result.content_read_back_error = 'Content read-back failed; response withheld. Do not repeat the import blindly.';
154
+ }
155
+ }
156
+ return result;
157
+ }
@@ -0,0 +1,139 @@
1
+ // Native APIM object deletion. Never cascade to consumers, providers or credentials.
2
+ import { z } from 'zod';
3
+ import { IntegrationError, odataKey } from './integration-http.mjs';
4
+ import { confirmation, entity, key, list, OVERWRITE_ANNOTATIONS, requireConfirmation, sleep } from './integration-common.mjs';
5
+ import { registerApimCleanupTools } from './apim-cleanup-tools.mjs';
6
+
7
+ const apimName = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
8
+ const INACTIVE_STATES = new Set(['CREATED', 'UNDEPLOYED']);
9
+ const MAX_DEPENDENCIES = 1000;
10
+
11
+ export function registerApimDeleteTools(register, { request, readBackDelayMs = 5000 }) {
12
+ const pathFor = (collection, name) => `/${collection}(${key(name)})`;
13
+
14
+ async function named(collection, name) {
15
+ const { status, data } = await request('GET', pathFor(collection, name), { expect: [200, 404], withStatus: true });
16
+ if (status === 404) return null;
17
+ const value = entity(data);
18
+ if (value?.name !== name) throw new IntegrationError(`Cannot establish the identity of ${collection} ${name}; deletion refused`);
19
+ return value;
20
+ }
21
+
22
+ async function count(path, query) {
23
+ const text = await request('GET', `${path}/$count`, { query, accept: 'text/plain', responseType: 'text' });
24
+ if (typeof text !== 'string' || !/^\s*\d+\s*$/.test(text)) throw new IntegrationError('Invalid dependency count; deletion refused');
25
+ const value = Number(text.trim());
26
+ if (!Number.isSafeInteger(value)) throw new IntegrationError('Unsafe dependency count; deletion refused');
27
+ return value;
28
+ }
29
+
30
+ const deployments = name => count('/APIProxyDeployments', { $filter: `name eq ${odataKey(name)}` });
31
+
32
+ async function productDependencies(name) {
33
+ const path = `${pathFor('APIProxies', name)}/apiProducts`;
34
+ const names = new Set();
35
+ let expected = null;
36
+ let offset = 0;
37
+ do {
38
+ const raw = await request('GET', path, {
39
+ query: { $select: 'name', $orderby: 'name', $top: 100, $skip: offset, $inlinecount: 'allpages' }, unwrap: false
40
+ });
41
+ const page = raw?.d ?? raw;
42
+ const total = typeof page?.__count === 'string' && /^\d+$/.test(page.__count) ? Number(page.__count) : NaN;
43
+ if (!Number.isSafeInteger(total) || total > MAX_DEPENDENCIES || (expected !== null && total !== expected)) {
44
+ throw new IntegrationError('Cannot establish a complete stable product dependency list; deletion refused');
45
+ }
46
+ expected = total;
47
+ const rows = list(page);
48
+ if (!Array.isArray(page?.results) || (rows.length === 0 && names.size < expected)) {
49
+ throw new IntegrationError('Incomplete product dependency page; deletion refused');
50
+ }
51
+ for (const row of rows) {
52
+ if (typeof row?.name !== 'string' || !row.name || names.has(row.name)) {
53
+ throw new IntegrationError('Invalid or repeated product dependency; deletion refused');
54
+ }
55
+ names.add(row.name);
56
+ }
57
+ if (names.size > expected) throw new IntegrationError('Product dependencies changed during inspection; deletion refused');
58
+ offset += rows.length;
59
+ } while (names.size < expected);
60
+ if (await count(path) !== expected) throw new IntegrationError('Product dependencies changed during inspection; deletion refused');
61
+ return [...names];
62
+ }
63
+
64
+ async function inspectProduct(name) {
65
+ if (!(await named('APIProducts', name))) return null;
66
+ return { name, blockingApplicationCount: await count(`${pathFor('APIProducts', name)}/applications`) };
67
+ }
68
+
69
+ async function inspectProxy(name) {
70
+ const proxy = await named('APIProxies', name);
71
+ if (!proxy) {
72
+ if (await deployments(name)) throw new IntegrationError(`API proxy ${name} has no definition but still has deployment records; deletion is unverified`);
73
+ return null;
74
+ }
75
+ return { name, state: proxy.state, blockingProducts: await productDependencies(name) };
76
+ }
77
+
78
+ async function remove(collection, name) {
79
+ const { status } = await request('DELETE', pathFor(collection, name), {
80
+ expect: [200, 202, 204, 404], responseType: 'text', withStatus: true
81
+ });
82
+ // A successful HTTP response is not proof of deletion. Never retry DELETE.
83
+ for (let attempt = 0; attempt < 4; attempt += 1) {
84
+ if (attempt) await sleep(readBackDelayMs);
85
+ const definition = await named(collection, name);
86
+ if (!definition && (collection !== 'APIProxies' || await deployments(name) === 0)) {
87
+ return { name, ...(status === 404 ? { already_absent: true } : { deleted: true }), deletion_verified: true };
88
+ }
89
+ }
90
+ return { name, deleted: false, deletion_verified: false, pending: true,
91
+ note: 'Deletion is not yet verified: a definition or deployment record remains. Re-read before another deletion attempt.' };
92
+ }
93
+
94
+ async function deleteProduct(name) {
95
+ const product = await inspectProduct(name);
96
+ if (!product) return { name, already_absent: true, deletion_verified: true };
97
+ if (product.blockingApplicationCount) {
98
+ throw new IntegrationError(`API product ${name} is associated with ${product.blockingApplicationCount} application(s); deletion refused`);
99
+ }
100
+ return remove('APIProducts', name);
101
+ }
102
+
103
+ async function deleteProxy(name, { allow_deployed = false } = {}) {
104
+ const proxy = await inspectProxy(name);
105
+ if (!proxy) return { name, already_absent: true, deletion_verified: true };
106
+ if (proxy.blockingProducts.length) {
107
+ throw new IntegrationError(`API proxy ${name} is associated with ${proxy.blockingProducts.length} product(s); delete or disassociate those products first`);
108
+ }
109
+ if (proxy.state === 'DEPLOYED') {
110
+ if (!allow_deployed) throw new IntegrationError(`API proxy ${name} is deployed; allow_deployed=true is required to approve runtime removal`);
111
+ } else if (!INACTIVE_STATES.has(proxy.state)) {
112
+ throw new IntegrationError(`API proxy ${name} has an unknown or transitional lifecycle state; deletion refused`);
113
+ }
114
+ return remove('APIProxies', name);
115
+ }
116
+
117
+ register('apim_delete_api_product', 'Delete API product',
118
+ 'Delete exactly one API product, including a published product, only when no application associations exist. '
119
+ + 'Never detaches or deletes applications or proxies. Verifies named-object absence after DELETE. '
120
+ + 'Requires confirmation exactly equal to DELETE API PRODUCT <name>.',
121
+ z.object({ name: apimName, confirmation }).strict(), OVERWRITE_ANNOTATIONS,
122
+ async ({ name, confirmation: approved }) => {
123
+ requireConfirmation(approved, `DELETE API PRODUCT ${name}`);
124
+ return deleteProduct(name);
125
+ });
126
+
127
+ register('apim_delete_api_proxy', 'Delete API proxy',
128
+ 'Delete exactly one API proxy after proving it has no product associations. '
129
+ + 'Deployed proxies require allow_deployed=true to approve removal from runtime. '
130
+ + 'Uses native deletion, never guessed undeploy endpoints; verifies definition and deployment-record absence. '
131
+ + 'Never deletes providers, KVMs or credentials. Requires confirmation exactly equal to DELETE API PROXY <name>.',
132
+ z.object({ name: apimName, allow_deployed: z.boolean().default(false), confirmation }).strict(), OVERWRITE_ANNOTATIONS,
133
+ async ({ name, allow_deployed, confirmation: approved }) => {
134
+ requireConfirmation(approved, `DELETE API PROXY ${name}`);
135
+ return deleteProxy(name, { allow_deployed });
136
+ });
137
+
138
+ registerApimCleanupTools(register, { inspectProduct, inspectProxy, deleteProduct, deleteProxy });
139
+ }
@@ -0,0 +1,162 @@
1
+ // Bounded, self-contained consumer documentation. Never return examples or arbitrary extension payloads.
2
+ import { createHash } from 'node:crypto';
3
+ import { isIP } from 'node:net';
4
+ import { IntegrationError } from './integration-http.mjs';
5
+
6
+ export const MAX_DOCUMENTATION_BYTES = 1024 * 1024;
7
+ export const DOCUMENTATION_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
8
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ const fail = message => { throw new IntegrationError(`API documentation ${message}`); };
10
+
11
+ function localReference(spec, ref) {
12
+ if (typeof ref !== 'string' || !(ref === '#' || ref.startsWith('#/'))) fail('must be self-contained; external references are not allowed');
13
+ if (ref === '#') return spec;
14
+ let pointer;
15
+ try { pointer = decodeURIComponent(ref.slice(2)); } catch { fail('contains an invalid local reference'); }
16
+ let target = spec;
17
+ for (const token of pointer.split('/')) {
18
+ if (/~(?:[^01]|$)/.test(token)) fail('contains an invalid local reference');
19
+ const part = token.replaceAll('~1', '/').replaceAll('~0', '~');
20
+ if ((!object(target) && !Array.isArray(target)) || !Object.hasOwn(target, part)) fail('contains an unresolved local reference');
21
+ target = target[part];
22
+ }
23
+ return target;
24
+ }
25
+
26
+ function resolve(spec, value) {
27
+ const seen = new Set();
28
+ while (object(value) && typeof value.$ref === 'string') {
29
+ const { $ref: ref, ...siblings } = value;
30
+ if (seen.has(ref)) fail('contains a cyclic structural reference');
31
+ seen.add(ref);
32
+ const target = localReference(spec, ref);
33
+ value = object(target) ? { ...target, ...siblings } : target;
34
+ }
35
+ return value;
36
+ }
37
+
38
+ function publicUrl(value) {
39
+ let url;
40
+ try { url = new URL(value); } catch { fail('must use an absolute HTTPS public runtime URL'); }
41
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
42
+ || isIP(url.hostname.replace(/^\[|\]$/g, '')) || !url.hostname.includes('.')
43
+ || /(?:^|\.)(?:localhost|local|internal|test|invalid)$/.test(url.hostname)) {
44
+ fail('must use a credential-free HTTPS public runtime URL without query or fragment');
45
+ }
46
+ return url.href.replace(/\/$/, '');
47
+ }
48
+
49
+ /** Validate input without dereferencing URLs or returning document content in errors. */
50
+ export function parseApiDocumentation(text, runtimeUrl, { allowMissingVersion = false } = {}) {
51
+ if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > MAX_DOCUMENTATION_BYTES) fail('JSON exceeds the 1 MiB limit');
52
+ let spec;
53
+ try { spec = JSON.parse(text); } catch { fail('must be valid OpenAPI JSON'); }
54
+ if (!object(spec) || !(spec.swagger === '2.0' || /^3\.(?:0|1)\.\d+$/.test(spec.openapi ?? ''))) fail('requires swagger 2.0 or openapi 3.0.x/3.1.x');
55
+ if (!object(spec.info) || typeof spec.info.title !== 'string' || !spec.info.title.trim()) fail('requires info.title');
56
+ if ((!allowMissingVersion || spec.info.version !== undefined)
57
+ && (typeof spec.info.version !== 'string' || !spec.info.version.trim())) fail('requires info.version');
58
+ if (!object(spec.paths)) fail('requires an object paths');
59
+ const expected = runtimeUrl === undefined ? undefined : publicUrl(runtimeUrl);
60
+ const checkUrl = value => {
61
+ const canonical = publicUrl(value);
62
+ if (expected !== undefined && canonical !== expected) fail('runtime URL does not match the named proxy');
63
+ return canonical;
64
+ };
65
+ const checkServers = value => {
66
+ if (value === undefined) return;
67
+ if (!Array.isArray(value) || !value.length) fail('servers must be a nonempty array');
68
+ for (const server of value) {
69
+ if (!object(server) || typeof server.url !== 'string' || /[{}]/.test(server.url)) fail('requires concrete runtime servers without variables');
70
+ checkUrl(server.url);
71
+ }
72
+ };
73
+ let servers;
74
+ if (spec.swagger === '2.0') {
75
+ if (!Array.isArray(spec.schemes) || spec.schemes.length !== 1 || spec.schemes[0] !== 'https'
76
+ || typeof spec.host !== 'string' || /[\/?#@]/.test(spec.host)
77
+ || typeof spec.basePath !== 'string' || !spec.basePath.startsWith('/')) fail('requires HTTPS schemes, host and basePath');
78
+ servers = [checkUrl(`https://${spec.host}${spec.basePath}`)];
79
+ } else {
80
+ if (!Array.isArray(spec.servers) || !spec.servers.length) fail('requires explicit runtime servers');
81
+ servers = spec.servers.map(server => {
82
+ if (!object(server) || typeof server.url !== 'string' || /[{}]/.test(server.url)) fail('requires concrete runtime servers without variables');
83
+ return checkUrl(server.url);
84
+ });
85
+ }
86
+ const stack = [spec];
87
+ while (stack.length) {
88
+ const value = stack.pop();
89
+ if (!value || typeof value !== 'object') continue;
90
+ for (const [key, item] of Object.entries(value)) {
91
+ if (key === '$ref') localReference(spec, item);
92
+ if (item && typeof item === 'object') stack.push(item);
93
+ }
94
+ }
95
+ for (const [path, original] of Object.entries(spec.paths)) {
96
+ if (path.startsWith('x-')) continue;
97
+ const item = resolve(spec, original);
98
+ if (!path.startsWith('/') || path.length > 2048 || /[?#\r\n]/.test(path) || !object(item)) fail('contains an invalid or overlong path item');
99
+ checkServers(item.servers);
100
+ for (const method of DOCUMENTATION_METHODS) {
101
+ const operation = item[method];
102
+ if (operation === undefined) continue;
103
+ if (!object(operation) || !object(operation.responses)) fail('operations require an object responses');
104
+ checkServers(operation.servers);
105
+ }
106
+ }
107
+ return { spec, servers: [...new Set(servers)] };
108
+ }
109
+
110
+ const bounded = value => typeof value === 'string' ? value.slice(0, 512) : undefined;
111
+ const security = value => Array.isArray(value) ? value.filter(object).map(requirement => Object.fromEntries(
112
+ Object.entries(requirement).map(([name, scopes]) => [name, Array.isArray(scopes) ? scopes.filter(scope => typeof scope === 'string').map(bounded) : []])
113
+ )) : [];
114
+
115
+ /** Safe structural summary, with an optional selected operation instead of raw schemas or examples. */
116
+ export function summarizeApiDocumentation(text, { path, method, operationOffset = 0 } = {}) {
117
+ const { spec, servers } = parseApiDocumentation(text, undefined, { allowMissingVersion: true });
118
+ const schemes = spec.swagger === '2.0' ? spec.securityDefinitions : spec.components?.securitySchemes;
119
+ const inventory = [];
120
+ for (const [itemPath, original] of Object.entries(spec.paths)) {
121
+ if (itemPath.startsWith('x-')) continue;
122
+ const item = resolve(spec, original);
123
+ for (const verb of DOCUMENTATION_METHODS) if (object(item[verb])) inventory.push({ path: itemPath, method: verb.toUpperCase(), operation_id: bounded(item[verb].operationId) ?? null });
124
+ }
125
+ const result = {
126
+ title: bounded(spec.info.title), version: bounded(spec.info.version) ?? null, openapi_version: spec.openapi ?? spec.swagger,
127
+ sha256: createHash('sha256').update(text).digest('hex'), bytes: Buffer.byteLength(text), servers,
128
+ path_count: Object.keys(spec.paths).filter(value => !value.startsWith('x-')).length, operation_count: inventory.length,
129
+ security_schemes: Object.entries(object(schemes) ? schemes : {}).map(([name, original]) => {
130
+ const scheme = resolve(spec, original);
131
+ return {
132
+ name, type: bounded(scheme?.type) ?? null, scheme: bounded(scheme?.scheme) ?? null,
133
+ in: bounded(scheme?.in) ?? null, parameter_name: bounded(scheme?.name) ?? null,
134
+ flows: object(scheme?.flows) ? Object.keys(scheme.flows) : [],
135
+ scopes: Object.keys(scheme?.scopes ?? {}).concat(...Object.values(scheme?.flows ?? {}).map(flow => Object.keys(flow?.scopes ?? {})))
136
+ };
137
+ }),
138
+ security: security(spec.security), operations: inventory.slice(operationOffset, operationOffset + 100),
139
+ operation_offset: operationOffset, next_operation_offset: operationOffset + 100 < inventory.length ? operationOffset + 100 : null
140
+ };
141
+ if (path !== undefined || method !== undefined) {
142
+ const verb = method?.toLowerCase();
143
+ const item = resolve(spec, spec.paths[path]);
144
+ const operation = item?.[verb];
145
+ if (!DOCUMENTATION_METHODS.includes(verb) || !object(operation)) fail('selected operation does not exist');
146
+ const parameters = new Map();
147
+ for (const original of [...(Array.isArray(item.parameters) ? item.parameters : []), ...(Array.isArray(operation.parameters) ? operation.parameters : [])]) {
148
+ const parameter = resolve(spec, original);
149
+ parameters.set(JSON.stringify([parameter?.in, parameter?.name]), parameter);
150
+ }
151
+ result.operation = {
152
+ path, method: verb.toUpperCase(), operation_id: bounded(operation.operationId) ?? null,
153
+ summary: bounded(operation.summary) ?? null, security: security(operation.security ?? spec.security),
154
+ parameters: [...parameters.values()]
155
+ .map(parameter => ({ name: bounded(parameter?.name) ?? null, in: bounded(parameter?.in) ?? null, required: parameter?.required === true, type: bounded(parameter?.type ?? resolve(spec, parameter?.schema)?.type) ?? null, ref: bounded(parameter?.$ref) ?? null })),
156
+ request_content_types: spec.swagger === '2.0' ? (operation.consumes ?? spec.consumes ?? []).filter(value => typeof value === 'string').map(bounded) : Object.keys(resolve(spec, operation.requestBody)?.content ?? {}),
157
+ response_statuses: Object.keys(operation.responses)
158
+ };
159
+ if (Buffer.byteLength(JSON.stringify(result.operation)) > 32 * 1024) fail('selected operation summary exceeds 32 KiB');
160
+ }
161
+ return result;
162
+ }
@@ -0,0 +1,141 @@
1
+ // Static bundle evidence only: no policy/source text or effective-runtime security claims.
2
+ import { z } from 'zod';
3
+ import { strFromU8 } from 'fflate';
4
+ import { apiProxyLayout, bundlePolicyNames, bundleJwtIssuers, xmlLocate, xmlChildren, xmlText, xmlAttributes } from './apim-bundle.mjs';
5
+ import { exportApiProxyBundle, readApiProxy } from './apim-content.mjs';
6
+ import { bundleDigest } from './iflow-bundle.mjs';
7
+ import { stateDigest } from './integration-state.mjs';
8
+ import { READ_ONLY_ANNOTATIONS } from './integration-common.mjs';
9
+ import { IntegrationError } from './integration-http.mjs';
10
+
11
+ const local = element => element.name.split(':').at(-1);
12
+ const identifier = value => typeof value === 'string' && /^[A-Za-z0-9_.& -]{1,255}$/.test(value) ? value : null;
13
+ const field = (xml, element, name) => xmlChildren(xml, element).find(child => local(child) === name);
14
+ const value = (xml, element, name) => {
15
+ const child = field(xml, element, name);
16
+ return child ? xmlText(xml, child) : null;
17
+ };
18
+ const text = (files, path) => strFromU8(files.get(path));
19
+
20
+ export function inspectApiProxyBundle(files, name) {
21
+ const layout = apiProxyLayout(files, name);
22
+ const rootXml = text(files, layout.root);
23
+ const root = xmlLocate(rootXml, []);
24
+ const policyLists = xmlChildren(rootXml, root).filter(child => local(child) === 'policies');
25
+ if (policyLists.length > 1) throw new IntegrationError('API proxy bundle has ambiguous policy registrations');
26
+ const names = bundlePolicyNames(files, name);
27
+ const registrations = policyLists[0] ? xmlChildren(rootXml, policyLists[0]).filter(child => local(child) === 'policy') : [];
28
+ const attachments = [];
29
+ const references = [];
30
+ const unresolved = [];
31
+ const addUnresolved = (source, reason) => {
32
+ if (!unresolved.some(item => item.source === source && item.reason === reason)) unresolved.push({ source, reason });
33
+ };
34
+ const addReference = (source, kind, raw, dynamic = false) => {
35
+ const alias = !dynamic && identifier(raw);
36
+ if (alias) {
37
+ if (!references.some(item => item.source === source && item.kind === kind && item.name === alias)) references.push({ source, kind, name: alias });
38
+ } else addUnresolved(source, `dynamic_or_unrecognized_${kind}`);
39
+ };
40
+ const scanReferences = (xml, element, source) => {
41
+ const attributes = xmlAttributes(xml, element);
42
+ if (attributes.ref) addUnresolved(source, 'dynamic_policy_reference');
43
+ if (local(element) === 'KeyValueMapOperations') {
44
+ if (attributes.mapIdentifier !== undefined) addReference(source, 'kvm', attributes.mapIdentifier, Boolean(attributes.ref));
45
+ else addUnresolved(source, 'dynamic_or_unrecognized_kvm');
46
+ }
47
+ const kind = ({ KeyStore: 'keystore', TrustStore: 'truststore', KeyAlias: 'certificate', CertificateAlias: 'certificate', certificate_alias: 'certificate', provider_id: 'provider', Provider: 'provider', APIProvider: 'provider' })[local(element)];
48
+ if (kind) addReference(source, kind, xmlText(xml, element), Boolean(attributes.ref) || xmlChildren(xml, element).length > 0);
49
+ for (const child of xmlChildren(xml, element)) scanReferences(xml, child, source);
50
+ };
51
+ const policies = names.map((policyName, index) => {
52
+ if (!identifier(policyName)) throw new IntegrationError('API proxy has an unsupported policy identity');
53
+ const path = `${layout.dir}Policy/${policyName}.xml`;
54
+ const registeredType = identifier(xmlAttributes(rootXml, registrations[index]).type);
55
+ if (!files.has(path)) {
56
+ addUnresolved(policyName, 'missing_policy_document');
57
+ return { name: policyName, registered_type: registeredType, type: null, document_present: false };
58
+ }
59
+ const xml = text(files, path);
60
+ const policy = xmlLocate(xml, []);
61
+ const type = identifier(local(policy));
62
+ if (['Javascript', 'PythonScript', 'JavaCallout', 'ServiceCallout'].includes(type)) addUnresolved(policyName, 'custom_or_dynamic_behavior');
63
+ const known = new Set(['AccessControl', 'AssignMessage', 'BasicAuth', 'DecodeJWT', 'ExtractVariable', 'GenerateJWT', 'GenerateSAMLAssertion', 'GetOAuthV2Info', 'Javascript', 'JSONThreatProtection', 'KeyValueMapOperations', 'LookupCache', 'OAuthV2', 'PopulateCache', 'PythonScript', 'Quota', 'RaiseFault', 'RegularExpressionProtection', 'ResponseCache', 'ServiceCallout', 'SetOAuthV2Info', 'SpikeArrest', 'ValidateSAMLAssertion', 'VerifyAPIKey', 'VerifyJWT', 'XMLThreatProtection']);
64
+ if (!known.has(type)) addUnresolved(policyName, 'unrecognized_policy_type');
65
+ if (registeredType && registeredType !== type) addUnresolved(policyName, 'policy_type_mismatch');
66
+ scanReferences(xml, policy, policyName);
67
+ return { name: policyName, registered_type: registeredType, type, document_present: true };
68
+ });
69
+ for (const [folder, endpoint] of [['APIProxyEndPoint', 'proxy'], ['APITargetEndPoint', 'target']]) {
70
+ for (const path of files.keys()) {
71
+ if (!path.startsWith(`${layout.dir}${folder}/`) || !path.endsWith('.xml')) continue;
72
+ const xml = text(files, path);
73
+ const endpointRoot = xmlLocate(xml, []);
74
+ const endpointName = identifier(value(xml, endpointRoot, 'name'));
75
+ const source = `${endpoint}:${endpointName ?? 'unknown'}`;
76
+ scanReferences(xml, endpointRoot, source);
77
+ const walk = (element, context) => {
78
+ const tag = local(element);
79
+ const next = { ...context };
80
+ if (['preFlow', 'postFlow', 'conditionalFlows', 'faultRules', 'defaultFaultRule'].includes(tag)) next.flow = tag;
81
+ if (tag === 'request' || tag === 'response') next.phase = tag;
82
+ if (tag === 'condition' && xmlText(xml, element)) return;
83
+ if (tag === 'step' || tag === 'Step') {
84
+ const policyName = identifier(value(xml, element, 'policy_name') ?? value(xml, element, 'Name'));
85
+ if (policyName) {
86
+ const condition = value(xml, element, 'condition') ?? value(xml, element, 'Condition');
87
+ attachments.push({ policy_name: policyName, endpoint, endpoint_name: endpointName, flow: next.flow ?? 'unknown', phase: next.phase ?? 'unknown', condition_present: Boolean(condition?.trim()) || Boolean(next.condition_present), registered: names.includes(policyName) });
88
+ } else addUnresolved(source, 'unrecognized_policy_attachment');
89
+ }
90
+ const condition = value(xml, element, 'condition') ?? value(xml, element, 'Condition');
91
+ if (condition?.trim()) next.condition_present = true;
92
+ for (const child of xmlChildren(xml, element)) walk(child, next);
93
+ };
94
+ walk(endpointRoot, {});
95
+ }
96
+ }
97
+ const unregisteredPolicyFiles = [...files.keys()].filter(path => path.startsWith(`${layout.dir}Policy/`) && path.endsWith('.xml') && !names.some(policyName => path === `${layout.dir}Policy/${policyName}.xml`)).map(path => identifier(path.slice(`${layout.dir}Policy/`.length, -4)) ?? 'unrecognized_identity');
98
+ if ([...files.keys()].some(path => path.startsWith(`${layout.dir}FileResource/`))) addUnresolved('file_resources', 'custom_or_dynamic_behavior');
99
+ const bundle_sha256 = bundleDigest(files);
100
+ return {
101
+ name, evidence: 'static_bundle', bundle_sha256, state_sha256: stateDigest(bundle_sha256), policies, attachments,
102
+ unregistered_policy_files: unregisteredPolicyFiles,
103
+ unattached_policies: names.filter(policyName => !attachments.some(item => item.policy_name === policyName)),
104
+ references, jwt_issuers: bundleJwtIssuers(files, name), unresolved,
105
+ effective_authentication: 'unverified', certificate_trust: 'unverified', effective_rate_limiting: 'unverified'
106
+ };
107
+ }
108
+
109
+ export async function inspectApiProxySecurity(client, name, { deadline } = {}) {
110
+ const files = await exportApiProxyBundle(client, name, { deadline });
111
+ if (!files) throw new IntegrationError(`API proxy ${name} does not exist`);
112
+ return inspectApiProxyBundle(files, name);
113
+ }
114
+
115
+ function safeRuntimeUrl(raw) {
116
+ try {
117
+ const url = new URL(raw);
118
+ if (!['http:', 'https:'].includes(url.protocol)) return null;
119
+ url.username = ''; url.password = ''; url.search = ''; url.hash = '';
120
+ return url.href;
121
+ } catch { return null; }
122
+ }
123
+
124
+ export function registerApimInspectionTools(register, client) {
125
+ const input = { name: z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/) };
126
+ register('apim_get_api_proxy_status', 'Get API proxy operational status',
127
+ 'Read allowlisted management state and deployment metadata. Runtime health remains unverified; runtime URL excludes credentials and query.', input, READ_ONLY_ANNOTATIONS,
128
+ async ({ name }) => {
129
+ const proxy = await readApiProxy(client, name);
130
+ if (!proxy) throw new IntegrationError(`API proxy ${name} does not exist`);
131
+ const safeScalar = entry => typeof entry === 'number' || typeof entry === 'boolean' ? entry : typeof entry === 'string' && /^[A-Za-z0-9_. :+TZ-]{1,255}$/.test(entry) ? entry : null;
132
+ const rawDeployments = proxy.deploymentInfo;
133
+ const deployments = Array.isArray(rawDeployments) ? rawDeployments : rawDeployments ? [rawDeployments] : [];
134
+ return { name, state: safeScalar(proxy.state), version: safeScalar(proxy.version),
135
+ deployment_metadata: deployments.length ? deployments.map(item => Object.fromEntries(['state', 'status', 'version', 'revision', 'deployedAt', 'deploymentTime'].filter(key => item[key] !== undefined).map(key => [key, safeScalar(item[key])]))) : null,
136
+ runtime_url: safeRuntimeUrl(proxy.runtime_url), runtime_health: 'unverified' };
137
+ });
138
+ register('apim_inspect_api_proxy_security', 'Inspect API proxy static security evidence',
139
+ 'Privately inspect exported policy registrations, attachments and identifiable dependency aliases. Returns digests and static metadata, never source or proof of effective security.', input, READ_ONLY_ANNOTATIONS,
140
+ ({ name }) => inspectApiProxySecurity(client, name));
141
+ }