sap-ai-dev-toolkit 0.9.9918 → 0.9.9921
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.
- package/.github/agents/sap-integration-architect.agent.md +32 -18
- package/.github/agents/sap-integration-developer.agent.md +28 -12
- package/.github/skills/integration-suite-delivery/SKILL.md +94 -37
- package/.github/skills/sap-integration-architect/SKILL.md +49 -25
- package/README.md +72 -15
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/inventory.md +97 -57
- package/package.json +1 -1
- package/scripts/check-live-integration.mjs +151 -0
- package/scripts/install-local.mjs +11 -4
- package/scripts/run-live-integration.mjs +55 -63
- package/src/apim-bundle.mjs +416 -13
- package/src/apim-cleanup-tools.mjs +145 -0
- package/src/apim-content.mjs +157 -0
- package/src/apim-delete-tools.mjs +139 -0
- package/src/apim-documentation.mjs +162 -0
- package/src/apim-inspection.mjs +141 -0
- package/src/apim-monitoring.mjs +140 -0
- package/src/apim-policy-steps.mjs +31 -0
- package/src/apim-runtime.mjs +262 -0
- package/src/apim-tools.mjs +202 -109
- package/src/cf-integration.mjs +46 -7
- package/src/cpi-async-monitoring.mjs +177 -0
- package/src/cpi-content.mjs +92 -0
- package/src/cpi-monitoring.mjs +225 -0
- package/src/cpi-operational.mjs +129 -0
- package/src/cpi-release.mjs +273 -0
- package/src/cpi-security.mjs +48 -0
- package/src/cpi-tools.mjs +184 -319
- package/src/devhub-retirement.mjs +345 -0
- package/src/devhub-tools.mjs +177 -0
- package/src/edge-monitoring.mjs +101 -0
- package/src/iflow-bundle.mjs +295 -7
- package/src/integration-alerts.mjs +248 -0
- package/src/integration-analysis.mjs +488 -0
- package/src/integration-capabilities.mjs +181 -0
- package/src/integration-collections.mjs +203 -0
- package/src/integration-common.mjs +140 -35
- package/src/integration-config.mjs +20 -14
- package/src/integration-credentials.mjs +16 -10
- package/src/integration-http.mjs +255 -67
- package/src/integration-messaging.mjs +118 -0
- package/src/integration-optional-config.mjs +122 -0
- package/src/integration-optional-tools.mjs +153 -0
- package/src/integration-release-store.mjs +410 -0
- package/src/integration-server.mjs +34 -16
- package/src/integration-state.mjs +31 -0
- package/src/integration-tools.mjs +44 -8
- package/src/integration-transport.mjs +184 -0
- package/src/integration-validation.mjs +26 -0
- package/src/integration-verify.mjs +143 -62
- package/src/open-connectors-tools.mjs +143 -0
- package/src/setup.mjs +86 -52
- package/tools.md +193 -26
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Read-only APIM analytics. This public V4 service is separate from Management.svc.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { READ_ONLY_ANNOTATIONS, isoDate, timeWindow, top } from './integration-common.mjs';
|
|
4
|
+
import { IntegrationError, odataKey } from './integration-http.mjs';
|
|
5
|
+
import { createCollectionPager, cursorSchema, decodeCollection } from './integration-collections.mjs';
|
|
6
|
+
import { registerApimInspectionTools } from './apim-inspection.mjs';
|
|
7
|
+
|
|
8
|
+
const ANALYTICS_ROOT = '/api/1.0/AnalyticsService';
|
|
9
|
+
const PATH = '/ResultsUTC';
|
|
10
|
+
const METRICS = Object.freeze([
|
|
11
|
+
['CallCount', 'total_calls'], ['SumErrors', 'total_errors'], ['SumPolicyError', 'total_policy_errors'],
|
|
12
|
+
['SumTargetError', 'total_target_errors'], ['SumResponseTime', 'total_response_time'], ['SumTargetResponseTime', 'total_target_response_time']
|
|
13
|
+
]);
|
|
14
|
+
// The service rejects derived aggregate aliases that collide with source entity properties.
|
|
15
|
+
const AGGREGATE = `$count as source_records,${METRICS.map(([name, alias]) => `${name} with sum as ${alias}`).join(',')},CreatedTime with max as data_through`;
|
|
16
|
+
const inputs = {
|
|
17
|
+
from: isoDate, to: isoDate,
|
|
18
|
+
proxy_names: z.array(z.string().min(1).max(100).regex(/^[^\x00-\x1f\x7f]+$/)).min(1).max(20).optional()
|
|
19
|
+
};
|
|
20
|
+
const pageInputs = { top: top(200, 100), cursor: cursorSchema };
|
|
21
|
+
const granularities = { minute: ['Minute', 60000], hour: ['Hour', 3600000], day: ['Day', 86400000] };
|
|
22
|
+
|
|
23
|
+
function selection(args) {
|
|
24
|
+
const window = timeWindow(args.from, args.to);
|
|
25
|
+
const predicates = [`CreatedTime ge ${window.from}`, `CreatedTime lt ${window.to}`];
|
|
26
|
+
if (args.proxy_names) predicates.push(`(${args.proxy_names.map(name => `ApiProxy eq ${odataKey(name)}`).join(' or ')})`);
|
|
27
|
+
return { window, filter: predicates.join(' and ') };
|
|
28
|
+
}
|
|
29
|
+
function number(value) {
|
|
30
|
+
return (typeof value === 'number' || typeof value === 'string' && value.trim() !== '') && Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) : null;
|
|
31
|
+
}
|
|
32
|
+
function timestamp(value) {
|
|
33
|
+
return isoDate.safeParse(value).success ? new Date(value).toISOString() : null;
|
|
34
|
+
}
|
|
35
|
+
function metrics(row) {
|
|
36
|
+
const values = Object.fromEntries(METRICS.map(([name, alias]) => [name, number(row?.[alias])]));
|
|
37
|
+
const sourceRecords = number(row?.source_records);
|
|
38
|
+
const empty = sourceRecords === 0 && METRICS.every(([, alias]) => row[alias] === null) && row.data_through === null;
|
|
39
|
+
const calls = values.CallCount;
|
|
40
|
+
const complete = empty || Number.isSafeInteger(sourceRecords) && Object.values(values).every(value => value !== null)
|
|
41
|
+
&& ['CallCount', 'SumErrors', 'SumPolicyError', 'SumTargetError'].every(name => Number.isSafeInteger(values[name]))
|
|
42
|
+
&& values.SumErrors <= calls && values.SumPolicyError <= calls && values.SumTargetError <= calls
|
|
43
|
+
&& (sourceRecords !== 0 || calls === 0) && (calls === 0 || timestamp(row?.data_through) !== null);
|
|
44
|
+
return {
|
|
45
|
+
source_records: Number.isSafeInteger(sourceRecords) ? sourceRecords : null,
|
|
46
|
+
calls, errors: values.SumErrors, policy_errors: values.SumPolicyError, target_errors: values.SumTargetError,
|
|
47
|
+
response_time_sum: values.SumResponseTime, target_response_time_sum: values.SumTargetResponseTime,
|
|
48
|
+
error_rate: complete && calls > 0 ? values.SumErrors / calls : null,
|
|
49
|
+
mean_response_time: complete && calls > 0 ? values.SumResponseTime / calls : null,
|
|
50
|
+
mean_target_response_time: complete && calls > 0 ? values.SumTargetResponseTime / calls : null,
|
|
51
|
+
data_through: timestamp(row?.data_through), metrics_complete: complete
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function coverage(window, dataThrough) {
|
|
55
|
+
return {
|
|
56
|
+
window, data_through: dataThrough,
|
|
57
|
+
watermark_lag_ms: dataThrough === null ? null : Math.max(0, Date.parse(window.to) - Date.parse(dataThrough)),
|
|
58
|
+
scope: 'service_reported_analytics', instantaneous_health: 'unverified',
|
|
59
|
+
note: 'Source watermark is the latest reported analytics timestamp; analytics may be delayed. Response times retain service units.'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function failure(error) {
|
|
63
|
+
const status = Number.isInteger(error?.status) ? error.status : null;
|
|
64
|
+
return { result: status === 401 ? 'unauthenticated' : status === 403 ? 'forbidden' : status === 404 || status === 501 ? 'unavailable' : 'error', status,
|
|
65
|
+
...(error?.code === 'INTEGRATION_DEADLINE' ? { stop_reason: 'deadline' } : {}) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function registerApimMonitoringTools(register, client) {
|
|
69
|
+
const pager = createCollectionPager(client, { root: ANALYTICS_ROOT });
|
|
70
|
+
register('apim_get_analytics', 'Get API Management analytics',
|
|
71
|
+
'Read server-aggregated calls, errors and weighted mean response times for explicit UTC [from,to), at most 31 days. The data watermark may lag; no traffic is no_data, not healthy. No request URLs or personal data are returned.',
|
|
72
|
+
inputs, READ_ONLY_ANNOTATIONS, async args => {
|
|
73
|
+
const { window, filter } = selection(args);
|
|
74
|
+
let decoded;
|
|
75
|
+
try {
|
|
76
|
+
decoded = decodeCollection(await client.request('GET', `${ANALYTICS_ROOT}${PATH}`, {
|
|
77
|
+
query: { $apply: `filter(${filter})/aggregate(${AGGREGATE})` }, unwrap: false, deadline: Date.now() + 30000
|
|
78
|
+
}));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return { window, complete: false, outcome: 'source_failure', source_result: failure(error), coverage: coverage(window, null) };
|
|
81
|
+
}
|
|
82
|
+
// An aggregate has zero or one row. Never sum a sampled/malformed multirow response locally.
|
|
83
|
+
const sourceComplete = !decoded.next && (decoded.total === null || decoded.total === decoded.items.length) && decoded.items.length <= 1;
|
|
84
|
+
const summary = metrics(decoded.items.length === 1 ? decoded.items[0] : null);
|
|
85
|
+
const empty = decoded.items.length === 0 && sourceComplete;
|
|
86
|
+
const complete = sourceComplete && (empty || summary.metrics_complete);
|
|
87
|
+
return { window, ...summary, complete, outcome: !complete ? 'partial' : empty || summary.calls === 0 || summary.source_records === 0 ? 'no_data' : 'data',
|
|
88
|
+
source_result: { result: 'ok', complete: sourceComplete }, coverage: coverage(window, summary.data_through) };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const grouped = (kind, args) => {
|
|
92
|
+
const { window, filter } = selection(args);
|
|
93
|
+
const series = kind === 'series';
|
|
94
|
+
let grouping = 'ApiProxy,ResponseCode';
|
|
95
|
+
if (series) {
|
|
96
|
+
const [field, duration] = granularities[args.granularity];
|
|
97
|
+
const buckets = Math.ceil(Date.parse(window.to) / duration) - Math.floor(Date.parse(window.from) / duration);
|
|
98
|
+
if (buckets > 1000) throw new IntegrationError('The analytics series must not exceed 1000 requested time buckets');
|
|
99
|
+
grouping = field;
|
|
100
|
+
}
|
|
101
|
+
const query = {
|
|
102
|
+
$apply: `filter(${filter}${series ? '' : ' and ResponseCode ge 400'})/groupby((${grouping}),aggregate(${AGGREGATE}))`,
|
|
103
|
+
$count: 'true'
|
|
104
|
+
};
|
|
105
|
+
// Authenticate and bind the cursor before entering the source-error boundary: invalid input is an error, not source failure.
|
|
106
|
+
pager.validateCursor(args.cursor);
|
|
107
|
+
return { window, grouping, query, series };
|
|
108
|
+
};
|
|
109
|
+
async function groupedPage(kind, args) {
|
|
110
|
+
const { window, grouping, query, series } = grouped(kind, args);
|
|
111
|
+
// page() rejects query/filter changes before network access; keep those errors visible to the caller.
|
|
112
|
+
try {
|
|
113
|
+
const page = await pager.page({ path: PATH, query, orderBy: grouping.split(',').map(name => `${name} asc`).join(','),
|
|
114
|
+
top: args.top, cursor: args.cursor, fallback: false, deadline: Date.now() + 30000, maxBytes: 90 * 1024,
|
|
115
|
+
project: row => ({ ...(series ? { bucket: timestamp(row[grouping]) } : {
|
|
116
|
+
proxy_name: typeof row.ApiProxy === 'string' ? row.ApiProxy.slice(0, 100) : null,
|
|
117
|
+
response_code: number(row.ResponseCode)
|
|
118
|
+
}), ...metrics(row) }) });
|
|
119
|
+
const metricsComplete = page.items.every(row => row.metrics_complete && (series ? row.bucket !== null : row.proxy_name !== null && Number.isInteger(row.response_code) && row.response_code >= 400));
|
|
120
|
+
const dataThrough = page.items.map(row => row.data_through).filter(Boolean).sort().at(-1) ?? null;
|
|
121
|
+
return { ...page, window, ...(series ? { granularity: args.granularity } : {}),
|
|
122
|
+
complete: page.complete && metricsComplete, metrics_complete: metricsComplete,
|
|
123
|
+
outcome: !metricsComplete || !['end', 'more'].includes(page.stop_reason) || page.source_result ? 'partial' : page.returned === 0 && page.complete ? 'no_data' : 'data',
|
|
124
|
+
coverage: { ...coverage(window, dataThrough), watermark_scope: 'returned_groups', totals_scope: 'each_group_only' } };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof IntegrationError && /cursor|continuation/i.test(error.message)) throw error;
|
|
127
|
+
return { window, items: [], returned: 0, total: null, complete: false, next_cursor: null,
|
|
128
|
+
stop_reason: error?.code === 'INTEGRATION_DEADLINE' ? 'deadline' : 'source_limit',
|
|
129
|
+
outcome: 'source_failure', source_result: failure(error), coverage: coverage(window, null) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
register('apim_get_analytics_series', 'Get API Management analytics series',
|
|
133
|
+
'Read paged V4 server aggregates grouped by Minute, Hour or Day, ordered by bucket. At most 1000 requested buckets and 200 rows per page. Means are weighted by calls; page metrics are not tenant totals.',
|
|
134
|
+
{ ...inputs, granularity: z.enum(['minute', 'hour', 'day']), ...pageInputs }, READ_ONLY_ANNOTATIONS,
|
|
135
|
+
args => groupedPage('series', args));
|
|
136
|
+
register('apim_get_api_error_breakdown', 'Get API Management error breakdown',
|
|
137
|
+
'Read paged V4 server aggregates grouped by exact API proxy and response code >=400 for explicit UTC [from,to). Group count and continuation apply to this error grouping, not all calls.',
|
|
138
|
+
{ ...inputs, ...pageInputs }, READ_ONLY_ANNOTATIONS, args => groupedPage('errors', args));
|
|
139
|
+
registerApimInspectionTools(register, client);
|
|
140
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Explicit complete-order policy assignment edits; source and condition values stay private.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { editPolicySteps } from './apim-bundle.mjs';
|
|
4
|
+
import { exportApiProxyBundle, importApiProxyBundle } from './apim-content.mjs';
|
|
5
|
+
import { bundleDigest, writeBundle } from './iflow-bundle.mjs';
|
|
6
|
+
import { confirmation, requireConfirmation, OVERWRITE_ANNOTATIONS } from './integration-common.mjs';
|
|
7
|
+
import { IntegrationError } from './integration-http.mjs';
|
|
8
|
+
|
|
9
|
+
export function registerApimPolicyStepTools(register, client, { readBackDelayMs } = {}) {
|
|
10
|
+
const name = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
|
|
11
|
+
register('apim_edit_policy_steps', 'Edit API proxy policy steps',
|
|
12
|
+
'Replace the complete ordered steps for one exact named proxy/target endpoint pre/post request/response phase. Requires the fresh exported bundle SHA-256; omitted step conditions preserve existing conditions (new steps are unconditional). Explicit remove_unreferenced deletes only unreferenced policy registrations/documents after a full static reference inventory, never shared scripts/resources. Ambiguous layouts fail before import. Import replaces the draft, imports a new revision and requests deployment. Content/order/condition verification is separate from runtime freshness. Requires confirmation exactly equal to EDIT API PROXY POLICY STEPS <name>.',
|
|
13
|
+
z.object({
|
|
14
|
+
name, expected_bundle_sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
15
|
+
endpoint: z.enum(['proxy', 'target']), endpoint_name: name,
|
|
16
|
+
flow: z.enum(['pre', 'post']), phase: z.enum(['request', 'response']),
|
|
17
|
+
steps: z.array(z.object({ policy_name: name, condition: z.string().max(4096).optional() }).strict()).max(200),
|
|
18
|
+
remove_unreferenced: z.array(name).max(200).optional(), confirmation
|
|
19
|
+
}).strict(), OVERWRITE_ANNOTATIONS,
|
|
20
|
+
async args => {
|
|
21
|
+
requireConfirmation(args.confirmation, `EDIT API PROXY POLICY STEPS ${args.name}`);
|
|
22
|
+
const files = await exportApiProxyBundle(client, args.name);
|
|
23
|
+
if (!files) throw new IntegrationError(`API proxy ${args.name} does not exist`);
|
|
24
|
+
if (bundleDigest(files) !== args.expected_bundle_sha256) throw new IntegrationError('API proxy bundle changed; inspect the fresh bundle and approve its digest before editing policy steps');
|
|
25
|
+
const assignments = editPolicySteps(files, args.name, args);
|
|
26
|
+
// Shared import compares every member, including exact assignment XML, order and conditions.
|
|
27
|
+
// A stale export therefore cannot verify an accepted import, even if runtime says DEPLOYED.
|
|
28
|
+
const deployment = await importApiProxyBundle(client, args.name, writeBundle(files), { readBackDelayMs });
|
|
29
|
+
return { ...deployment, updated: deployment.content_verified, assignments_verified: deployment.content_verified, ...assignments };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
|
|
3
|
+
import { apiProxyRuntimeUrl } from './apim-content.mjs';
|
|
4
|
+
import { READ_ONLY_ANNOTATIONS, entity, key, list, metadataSummary } from './integration-common.mjs';
|
|
5
|
+
import { IntegrationError } from './integration-http.mjs';
|
|
6
|
+
import { bundleJwtIssuers } from './apim-bundle.mjs';
|
|
7
|
+
import { serviceKeyCredentials } from './cf-destination.mjs';
|
|
8
|
+
import { readBundle } from './iflow-bundle.mjs';
|
|
9
|
+
|
|
10
|
+
const APIM_BASE = '/apiportal/api/1.0/Management.svc';
|
|
11
|
+
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
async function responseText(response) {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
let length = 0;
|
|
16
|
+
for await (const chunk of response.body ?? []) {
|
|
17
|
+
length += chunk.byteLength;
|
|
18
|
+
if (length > MAX_RESPONSE_BYTES) throw new IntegrationError('API response exceeded the safe size limit');
|
|
19
|
+
chunks.push(chunk);
|
|
20
|
+
}
|
|
21
|
+
return Buffer.concat(chunks, length).toString('utf8');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Execute a bounded API read with exactly one credential retained only in memory. */
|
|
25
|
+
export async function runApiProxyGet({ apim, consumerKey, bearerToken, proxyName, path, query = {}, env = process.env, fetchImpl = undiciFetch }) {
|
|
26
|
+
if (!apim) throw new IntegrationError('Configure APIM credentials to resolve the deployed API proxy before testing it');
|
|
27
|
+
const credentials = [consumerKey, bearerToken].filter(value => value !== undefined);
|
|
28
|
+
if (credentials.length !== 1 || typeof credentials[0] !== 'string' || !credentials[0] || /[\r\n]/.test(credentials[0])) {
|
|
29
|
+
throw new IntegrationError('Provide exactly one private consumer API key or bearer token');
|
|
30
|
+
}
|
|
31
|
+
const safeResult = value => redactPrivateValues(value, credentials);
|
|
32
|
+
if (!/^\/(?:\$metadata|[A-Za-z_][A-Za-z0-9_]*)$/.test(path)) {
|
|
33
|
+
throw new IntegrationError('API read path must be /$metadata or one entity set; navigation, encoded paths and query strings are not allowed');
|
|
34
|
+
}
|
|
35
|
+
for (const name of Object.keys(query)) {
|
|
36
|
+
if (!['$top', '$select', '$filter'].includes(name)) throw new IntegrationError('Only $top, $select and $filter are allowed for API reads');
|
|
37
|
+
}
|
|
38
|
+
const metadata = path === '/$metadata';
|
|
39
|
+
if (metadata && Object.keys(query).length) throw new IntegrationError('Metadata reads do not accept entity query options');
|
|
40
|
+
const top = Number(query.$top ?? 1);
|
|
41
|
+
if (!metadata && (!Number.isInteger(top) || top < 1 || top > 10)) throw new IntegrationError('API reads require $top between 1 and 10');
|
|
42
|
+
const proxy = await apim.request('GET', `${APIM_BASE}/APIProxies(${key(proxyName)})`, {
|
|
43
|
+
query: { $expand: 'proxyEndPoints,proxyEndPoints/virtualhosts,deploymentInfo' }
|
|
44
|
+
});
|
|
45
|
+
if (proxy?.state !== 'DEPLOYED') throw new IntegrationError('The API proxy is not deployed');
|
|
46
|
+
let defaultHost = null;
|
|
47
|
+
if (!list(list(proxy?.proxyEndPoints)[0]?.virtualhosts).length) {
|
|
48
|
+
defaultHost = list(await apim.request('GET', `${APIM_BASE}/VirtualHosts`, { query: { $filter: 'isDefault eq true' } }))[0];
|
|
49
|
+
}
|
|
50
|
+
const runtime = apiProxyRuntimeUrl(proxy, defaultHost);
|
|
51
|
+
if (!runtime) throw new IntegrationError('The API proxy has no runtime URL');
|
|
52
|
+
const base = new URL(runtime);
|
|
53
|
+
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) {
|
|
54
|
+
throw new IntegrationError('API smoke requests require a credential-free HTTPS runtime URL from API Management');
|
|
55
|
+
}
|
|
56
|
+
const target = new URL(base.href);
|
|
57
|
+
target.pathname = `${base.pathname.replace(/\/+$/, '')}${path}`;
|
|
58
|
+
for (const [name, value] of Object.entries(query)) target.searchParams.set(name, String(value));
|
|
59
|
+
if (!metadata) target.searchParams.set('$top', String(top));
|
|
60
|
+
const httpProxy = env.HTTP_PROXY || env.http_proxy;
|
|
61
|
+
const httpsProxy = env.HTTPS_PROXY || env.https_proxy;
|
|
62
|
+
const dispatcher = httpProxy || httpsProxy
|
|
63
|
+
? new EnvHttpProxyAgent({ httpProxy, httpsProxy, noProxy: env.NO_PROXY || env.no_proxy })
|
|
64
|
+
: undefined;
|
|
65
|
+
try {
|
|
66
|
+
let response;
|
|
67
|
+
try {
|
|
68
|
+
response = await fetchImpl(target.href, {
|
|
69
|
+
method: 'GET',
|
|
70
|
+
headers: { ...(bearerToken === undefined ? { apikey: consumerKey } : { authorization: `Bearer ${bearerToken}` }), accept: metadata ? 'application/xml' : 'application/json', 'accept-encoding': 'gzip, deflate' },
|
|
71
|
+
redirect: 'manual',
|
|
72
|
+
signal: AbortSignal.timeout(30000),
|
|
73
|
+
...(dispatcher ? { dispatcher } : {})
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
throw new IntegrationError('API proxy GET failed (network error or timeout); credentials were not returned');
|
|
77
|
+
}
|
|
78
|
+
const status = response.status;
|
|
79
|
+
if (status !== 200) {
|
|
80
|
+
await response.body?.cancel().catch(() => {});
|
|
81
|
+
throw new IntegrationError(`API proxy GET failed (HTTP ${status}); response body withheld to protect credentials; redirects are not followed`);
|
|
82
|
+
}
|
|
83
|
+
let text;
|
|
84
|
+
try {
|
|
85
|
+
text = await responseText(response);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new IntegrationError('API response could not be read within the safe size limit; response withheld');
|
|
88
|
+
}
|
|
89
|
+
const result = { proxy: proxyName, runtime_url: base.href, path, status, content_type: response.headers.get('content-type') };
|
|
90
|
+
if (metadata) {
|
|
91
|
+
if (!/<(?:\w+:)?Edmx\b/.test(text)) throw new IntegrationError('API proxy returned HTTP 200 but not an OData metadata document');
|
|
92
|
+
return safeResult({ ...result, metadata: metadataSummary(text) });
|
|
93
|
+
}
|
|
94
|
+
let data;
|
|
95
|
+
try {
|
|
96
|
+
data = JSON.parse(text);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new IntegrationError('API proxy returned HTTP 200 but not JSON');
|
|
99
|
+
}
|
|
100
|
+
const rows = data?.d?.results ?? data?.value;
|
|
101
|
+
if (!Array.isArray(rows)) throw new IntegrationError('API proxy response is not an OData entity collection');
|
|
102
|
+
if (rows.length > top) throw new IntegrationError('API proxy did not honor the bounded $top request');
|
|
103
|
+
return safeResult({ ...result, returned: rows.length, fields: [...new Set(rows.flatMap(row => Object.keys(entity(row) ?? {})))] });
|
|
104
|
+
} finally {
|
|
105
|
+
await dispatcher?.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Redact private values without replacing JSON syntax or corrupting scalar literals. */
|
|
110
|
+
export function redactPrivateValues(value, secrets) {
|
|
111
|
+
const privateValues = [...new Set(secrets)].filter(value => typeof value === 'string' && value).sort((a, b) => b.length - a.length);
|
|
112
|
+
const redactText = text => {
|
|
113
|
+
for (const secret of privateValues) text = text.replaceAll(secret, '[redacted]');
|
|
114
|
+
return text;
|
|
115
|
+
};
|
|
116
|
+
const redact = entry => {
|
|
117
|
+
if (Array.isArray(entry)) return entry.map(redact);
|
|
118
|
+
if (entry && typeof entry === 'object') {
|
|
119
|
+
return Object.fromEntries(Object.entries(entry).map(([name, value]) => [redactText(name), redact(value)]));
|
|
120
|
+
}
|
|
121
|
+
if (typeof entry === 'string') return redactText(entry);
|
|
122
|
+
if (entry === undefined) return entry;
|
|
123
|
+
const text = String(entry);
|
|
124
|
+
const redacted = redactText(text);
|
|
125
|
+
return redacted === text ? entry : redacted;
|
|
126
|
+
};
|
|
127
|
+
return redact(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function oauthEndpoint(credentials) {
|
|
131
|
+
const auth = credentials?.uaa ?? credentials?.oauth ?? credentials;
|
|
132
|
+
const clientId = auth?.clientid ?? auth?.client_id;
|
|
133
|
+
const clientSecret = auth?.clientsecret ?? auth?.client_secret;
|
|
134
|
+
if (typeof clientId !== 'string' || !clientId || typeof clientSecret !== 'string' || !clientSecret) {
|
|
135
|
+
throw new IntegrationError('OAuth probing requires a client ID and client secret; certificate-only service keys are not supported');
|
|
136
|
+
}
|
|
137
|
+
const address = auth.tokenurl ?? auth.token_url ?? auth.url;
|
|
138
|
+
let url;
|
|
139
|
+
try {
|
|
140
|
+
if (typeof address !== 'string' || /[\s@?#]/.test(address)) throw new Error();
|
|
141
|
+
url = new URL(address);
|
|
142
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw new Error();
|
|
143
|
+
if (url.pathname === '/') url.pathname = '/oauth/token';
|
|
144
|
+
if (url.pathname !== '/oauth/token') throw new Error();
|
|
145
|
+
} catch {
|
|
146
|
+
throw new IntegrationError('OAuth service key requires a credential-free HTTPS /oauth/token endpoint without a query or fragment');
|
|
147
|
+
}
|
|
148
|
+
return { url, clientId, clientSecret };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Acquire a narrowly scoped XSUAA token only after pinning its endpoint to fresh proxy policy. */
|
|
152
|
+
export async function runApiProxyOAuthGet({ apim, oauth, proxyName, path, query, env = process.env, fetchImpl = undiciFetch, execFileImpl }) {
|
|
153
|
+
if (!oauth || typeof oauth.scope !== 'string' || !oauth.scope || oauth.scope.length > 512 || /[\s\x00-\x1f\x7f]/.test(oauth.scope)) {
|
|
154
|
+
throw new IntegrationError('OAuth probing requires exactly one nonempty whitespace-free scope');
|
|
155
|
+
}
|
|
156
|
+
for (const reference of [oauth.service_instance, oauth.service_key]) {
|
|
157
|
+
if (typeof reference !== 'string' || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,255}$/.test(reference)) {
|
|
158
|
+
throw new IntegrationError('OAuth probing requires CF service instance and service key names, not credentials');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!apim) throw new IntegrationError('Configure APIM credentials before OAuth probing');
|
|
162
|
+
const bundle = await apim.request('GET', '/apiportal/api/1.0/Transport.svc/APIProxies', {
|
|
163
|
+
query: { name: proxyName }, accept: 'application/zip, application/octet-stream', responseType: 'bytes'
|
|
164
|
+
});
|
|
165
|
+
let issuers;
|
|
166
|
+
try {
|
|
167
|
+
issuers = bundleJwtIssuers(readBundle(bundle), proxyName);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new IntegrationError('The selected proxy bundle could not be inspected for an explicit VerifyJWT issuer');
|
|
170
|
+
}
|
|
171
|
+
if (!issuers.length) throw new IntegrationError('The selected proxy has no explicit HTTPS VerifyJWT issuer for OAuth probing');
|
|
172
|
+
let credentials;
|
|
173
|
+
try {
|
|
174
|
+
credentials = await serviceKeyCredentials(oauth.service_instance, oauth.service_key, 'OAuth', { env, execFileImpl });
|
|
175
|
+
} catch {
|
|
176
|
+
throw new IntegrationError('The referenced CF OAuth service key could not be read; credential details withheld');
|
|
177
|
+
}
|
|
178
|
+
const { url, clientId, clientSecret } = oauthEndpoint(credentials);
|
|
179
|
+
if (!issuers.some(issuer => new URL(issuer).href === url.href)) {
|
|
180
|
+
throw new IntegrationError('The OAuth token endpoint does not match an explicit VerifyJWT issuer of the selected proxy; no credentials were sent');
|
|
181
|
+
}
|
|
182
|
+
const httpProxy = env.HTTP_PROXY || env.http_proxy;
|
|
183
|
+
const httpsProxy = env.HTTPS_PROXY || env.https_proxy;
|
|
184
|
+
const dispatcher = httpProxy || httpsProxy
|
|
185
|
+
? new EnvHttpProxyAgent({ httpProxy, httpsProxy, noProxy: env.NO_PROXY || env.no_proxy })
|
|
186
|
+
: undefined;
|
|
187
|
+
let bearerToken;
|
|
188
|
+
try {
|
|
189
|
+
let response;
|
|
190
|
+
try {
|
|
191
|
+
response = await fetchImpl(url.href, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', 'accept-encoding': 'gzip, deflate' },
|
|
194
|
+
body: new URLSearchParams({ grant_type: 'client_credentials', scope: oauth.scope }).toString(),
|
|
195
|
+
redirect: 'manual', signal: AbortSignal.timeout(30000),
|
|
196
|
+
...(dispatcher ? { dispatcher } : {})
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
throw new IntegrationError('OAuth token request failed (network error or timeout); credential details withheld');
|
|
200
|
+
}
|
|
201
|
+
if (response.status !== 200) {
|
|
202
|
+
await response.body?.cancel().catch(() => {});
|
|
203
|
+
throw new IntegrationError(`OAuth token request failed (HTTP ${response.status}); response withheld; redirects are not followed`);
|
|
204
|
+
}
|
|
205
|
+
let token;
|
|
206
|
+
try {
|
|
207
|
+
token = JSON.parse(await responseText(response));
|
|
208
|
+
} catch {
|
|
209
|
+
throw new IntegrationError('OAuth token response was unreadable or invalid; response withheld');
|
|
210
|
+
}
|
|
211
|
+
if (typeof token?.access_token !== 'string' || !token.access_token || /[\s\x00-\x1f\x7f]/.test(token.access_token) || typeof token.token_type !== 'string' || token.token_type.toLowerCase() !== 'bearer') {
|
|
212
|
+
throw new IntegrationError('OAuth token response did not contain a usable bearer token; response withheld');
|
|
213
|
+
}
|
|
214
|
+
if (token.scope !== undefined && (typeof token.scope !== 'string' || token.scope.trim() !== oauth.scope)) {
|
|
215
|
+
throw new IntegrationError('OAuth token response did not grant exactly the requested scope; response withheld');
|
|
216
|
+
}
|
|
217
|
+
bearerToken = token.access_token;
|
|
218
|
+
} finally {
|
|
219
|
+
await dispatcher?.close();
|
|
220
|
+
}
|
|
221
|
+
const result = await runApiProxyGet({ apim, bearerToken, proxyName, path, query, env, fetchImpl });
|
|
222
|
+
return redactPrivateValues(result, [clientId, clientSecret, bearerToken]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The APIM runtime surface is available without Developer Hub; API-key mode alone requires it. */
|
|
226
|
+
export function registerApimRuntimeTools(register, { apim, devhub, env, fetchImpl, execFileImpl } = {}) {
|
|
227
|
+
const applicationId = z.string().min(1).max(256).regex(/^[A-Za-z0-9_-]+$/);
|
|
228
|
+
const proxyName = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
|
|
229
|
+
async function getApplication(id) {
|
|
230
|
+
if (!devhub) throw new IntegrationError('Configure Developer Hub credentials to use application_id API-key authentication');
|
|
231
|
+
const { status, data } = await devhub.request('GET', `/odata/1.0/data.svc/APIMgmt.Applications(${key(id)})`, {
|
|
232
|
+
query: { $expand: 'ToAPIProductsDetails,ToSubscriptions/ToAPIProduct,ToSubscriptions/ToRatePlan' },
|
|
233
|
+
expect: [200, 404], withStatus: true
|
|
234
|
+
});
|
|
235
|
+
if (status === 404) throw new IntegrationError(`Developer Hub application ${id} does not exist`);
|
|
236
|
+
return data;
|
|
237
|
+
}
|
|
238
|
+
register('apim_test_api', 'Test API with private API-key or OAuth credentials',
|
|
239
|
+
'Execute a GET-only OData metadata or bounded entity read against an APIM-resolved deployed proxy. Supply exactly one authentication source: application_id for a private Developer Hub key (requires DEVHUB), or oauth CF service/key references and one scope for external XSUAA client_credentials (works without DEVHUB). Fresh VerifyJWT issuer pinning precedes credential use. No keys, secrets, tokens, headers or business row values returned; no redirects or arbitrary URLs.',
|
|
240
|
+
{
|
|
241
|
+
application_id: applicationId.optional(),
|
|
242
|
+
oauth: z.object({
|
|
243
|
+
service_instance: z.string().regex(/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,255}$/),
|
|
244
|
+
service_key: z.string().regex(/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,255}$/),
|
|
245
|
+
scope: z.string().min(1).max(512).regex(/^[^\s\x00-\x1f\x7f]+$/)
|
|
246
|
+
}).strict().optional(),
|
|
247
|
+
proxy_name: proxyName,
|
|
248
|
+
path: z.string().regex(/^\/(?:\$metadata|[A-Za-z_][A-Za-z0-9_]*)$/),
|
|
249
|
+
query: z.object({
|
|
250
|
+
$top: z.number().int().min(1).max(10).optional(),
|
|
251
|
+
$select: z.string().min(1).max(1024).optional(),
|
|
252
|
+
$filter: z.string().min(1).max(2048).optional()
|
|
253
|
+
}).strict().optional()
|
|
254
|
+
}, READ_ONLY_ANNOTATIONS,
|
|
255
|
+
async ({ application_id, oauth, proxy_name, path, query }) => {
|
|
256
|
+
if ((application_id !== undefined) === (oauth !== undefined)) throw new IntegrationError('Provide exactly one authentication source: application_id or oauth');
|
|
257
|
+
if (oauth !== undefined) return runApiProxyOAuthGet({ apim, oauth, proxyName: proxy_name, path, query, env, fetchImpl, execFileImpl });
|
|
258
|
+
const application = await getApplication(application_id);
|
|
259
|
+
const result = await runApiProxyGet({ apim, consumerKey: application?.app_key, proxyName: proxy_name, path, query, env, fetchImpl });
|
|
260
|
+
return redactPrivateValues({ application_id, ...result }, [application?.app_key, application?.app_secret]);
|
|
261
|
+
});
|
|
262
|
+
}
|