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
|
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { INTEGRATION_CONFIG_ERROR } from './integration-config.mjs';
|
|
5
5
|
import { IntegrationError, odataKey } from './integration-http.mjs';
|
|
6
6
|
import { strFromU8 } from './iflow-bundle.mjs';
|
|
7
|
+
import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
|
|
7
8
|
|
|
8
9
|
export const INTEGRATION_MAX_OUTPUT_BYTES = 96 * 1024;
|
|
9
10
|
export const FILE_TEXT_CAP = 48 * 1024;
|
|
@@ -69,6 +70,9 @@ export function requireConfirmation(actual, expected) {
|
|
|
69
70
|
export function list(value) {
|
|
70
71
|
if (Array.isArray(value)) return value;
|
|
71
72
|
if (Array.isArray(value?.results)) return value.results;
|
|
73
|
+
if (Array.isArray(value?.value)) return value.value;
|
|
74
|
+
if (Array.isArray(value?.d?.results)) return value.d.results;
|
|
75
|
+
if (Array.isArray(value?.d)) return value.d;
|
|
72
76
|
return [];
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -78,7 +82,7 @@ export function entity(value) {
|
|
|
78
82
|
if (!value || typeof value !== 'object') return value;
|
|
79
83
|
const result = {};
|
|
80
84
|
for (const [name, entry] of Object.entries(value)) {
|
|
81
|
-
if (name.startsWith('__')) continue;
|
|
85
|
+
if (name.startsWith('__') || name.startsWith('@odata.') || name === '@count') continue;
|
|
82
86
|
if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.__deferred) continue;
|
|
83
87
|
result[name] = entry && typeof entry === 'object' && Array.isArray(entry.results) ? entity(entry.results) : entity(entry);
|
|
84
88
|
}
|
|
@@ -91,23 +95,23 @@ const PROVIDER_SECRET_KEYS = new Set(['password', 'clientSecret', 'userSecret',
|
|
|
91
95
|
export function withoutSecrets(value) {
|
|
92
96
|
if (Array.isArray(value)) return value.map(withoutSecrets);
|
|
93
97
|
if (!value || typeof value !== 'object') return value;
|
|
98
|
+
const semanticName = value.Name ?? value.name ?? value.ParameterKey;
|
|
94
99
|
return Object.fromEntries(Object.entries(value)
|
|
95
100
|
.filter(([name]) => !PROVIDER_SECRET_KEYS.has(name))
|
|
96
|
-
.map(([name, entry]) => [name,
|
|
101
|
+
.map(([name, entry]) => [name, /^(?:value|ParameterValue)$/i.test(name) && typeof semanticName === 'string'
|
|
102
|
+
&& /password|secret|token|username|clientid|authorization/i.test(semanticName) ? REDACTED : withoutSecrets(entry)]));
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
export const REDACTED = '[redacted]';
|
|
100
|
-
const SECRET_FIELD = /password|secret|passphrase|
|
|
106
|
+
const SECRET_FIELD = /password|secret|passphrase|private[_-]?key|api[_-]?key|app[_-]?key|consumer[_-]?key|secure[_-]?param|access[_-]?token|refresh[_-]?token|authorization|cookie|(?:^|[_-])token(?:$|[_-])|csrf[_-]?token/i;
|
|
101
107
|
const BINARY_FIELDS = new Set(['ArtifactContent', 'ResourceContent', 'PackageContent', 'CustomTagsConfigurationContent']);
|
|
102
108
|
|
|
103
|
-
/**
|
|
104
|
-
* Replace secret-looking values with [redacted] and omit base64 artifact content. Inside an object flagged
|
|
105
|
-
* encrypted/isEncrypted (APIM key value maps) every nested `value` is redacted as well.
|
|
106
|
-
*/
|
|
109
|
+
/** Redact known credential fields and semantic name/value pairs, not arbitrary source text. */
|
|
107
110
|
export function redactSecrets(value, encrypted = false) {
|
|
108
111
|
if (Array.isArray(value)) return value.map(entry => redactSecrets(entry, encrypted));
|
|
109
112
|
if (!value || typeof value !== 'object') return value;
|
|
110
|
-
const inEncrypted = encrypted || value.encrypted === true || value.isEncrypted === true;
|
|
113
|
+
const inEncrypted = encrypted || value.encrypted === true || value.encrypted === 'true' || value.isEncrypted === true || value.isEncrypted === 'true';
|
|
114
|
+
const semanticName = value.Name ?? value.name ?? value.ParameterKey;
|
|
111
115
|
const result = {};
|
|
112
116
|
for (const [name, entry] of Object.entries(value)) {
|
|
113
117
|
if (BINARY_FIELDS.has(name)) {
|
|
@@ -115,11 +119,11 @@ export function redactSecrets(value, encrypted = false) {
|
|
|
115
119
|
continue;
|
|
116
120
|
}
|
|
117
121
|
if (entry !== null && entry !== undefined && entry !== '' && typeof entry !== 'object'
|
|
118
|
-
&& (SECRET_FIELD.test(name) || (inEncrypted
|
|
122
|
+
&& (SECRET_FIELD.test(name) || ((inEncrypted || (typeof semanticName === 'string' && SECRET_FIELD.test(semanticName))) && /^(?:value|ParameterValue)$/i.test(name)))) {
|
|
119
123
|
result[name] = REDACTED;
|
|
120
124
|
continue;
|
|
121
125
|
}
|
|
122
|
-
result[name] = redactSecrets(entry, inEncrypted);
|
|
126
|
+
result[name] = redactSecrets(entry, inEncrypted || name === 'keyMapEntryValues');
|
|
123
127
|
}
|
|
124
128
|
return result;
|
|
125
129
|
}
|
|
@@ -136,7 +140,17 @@ export function capText(text, limit) {
|
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
export function isoToOdataDateTime(value) {
|
|
139
|
-
return new Date(value).toISOString().slice(0,
|
|
143
|
+
return new Date(value).toISOString().slice(0, -1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Shared UTC half-open window for monitoring; milliseconds remain significant. */
|
|
147
|
+
export function timeWindow(from, to) {
|
|
148
|
+
if (!isoDate.safeParse(from).success || !isoDate.safeParse(to).success) throw new IntegrationError('from and to must both be valid UTC timestamps');
|
|
149
|
+
const start = Date.parse(from);
|
|
150
|
+
const end = Date.parse(to);
|
|
151
|
+
if (end <= start) throw new IntegrationError('to must be after from');
|
|
152
|
+
if (end - start > 31 * 24 * 60 * 60 * 1000) throw new IntegrationError('The monitoring window must not exceed 31 days');
|
|
153
|
+
return { from: new Date(start).toISOString(), to: new Date(end).toISOString() };
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
function extensionOf(path) {
|
|
@@ -172,7 +186,9 @@ export function bundleFiles(bundle, filePath) {
|
|
|
172
186
|
// ---------------------------------------------------------------- schemas
|
|
173
187
|
|
|
174
188
|
export const confirmation = z.string().min(1).max(300);
|
|
175
|
-
export const isoDate = z.string().max(40)
|
|
189
|
+
export const isoDate = z.string().max(40)
|
|
190
|
+
.regex(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/, 'must be an explicit UTC timestamp')
|
|
191
|
+
.refine(value => Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 19) === value.slice(0, 19), 'must be a valid UTC date/time');
|
|
176
192
|
export const top = (max = 200, fallback = 100) => z.number().int().min(1).max(max).default(fallback);
|
|
177
193
|
export const secretValue = z.string().min(1).max(4096)
|
|
178
194
|
.describe('Write-only: sent to the tenant once and never echoed back by any tool.');
|
|
@@ -183,6 +199,97 @@ const ODATA_SEGMENT = String.raw`[A-Za-z][A-Za-z0-9_]{0,99}(?:\([^/?#]{0,400}\))
|
|
|
183
199
|
const ODATA_PATH = new RegExp(String.raw`^(?:/\$metadata|/${ODATA_SEGMENT}(?:/(?:${ODATA_SEGMENT}|\$count))*)$`);
|
|
184
200
|
const ODATA_OPTIONS = ['$filter', '$select', '$expand', '$orderby', '$top', '$skip', '$inlinecount'];
|
|
185
201
|
|
|
202
|
+
// Version 1: only implemented metadata projections authorize data access. Metadata discovery
|
|
203
|
+
// does not authorize raw reads of newly discovered roots or GET function imports.
|
|
204
|
+
const fields = names => value => pick(entity(value), names.split(','));
|
|
205
|
+
const CPI_PROJECTORS = {
|
|
206
|
+
IntegrationPackages: fields('Id,Name,ShortText,Version,Mode'),
|
|
207
|
+
IntegrationDesigntimeArtifacts: fields('Id,Name,Version,Description,PackageId'),
|
|
208
|
+
ValueMappingDesigntimeArtifacts: fields('Id,Name,Version,Description,PackageId'),
|
|
209
|
+
ScriptCollectionDesigntimeArtifacts: fields('Id,Name,Version,Description,PackageId'),
|
|
210
|
+
MessageMappingDesigntimeArtifacts: fields('Id,Name,Version,Description,PackageId'),
|
|
211
|
+
Configurations: fields('ParameterKey,DataType'),
|
|
212
|
+
IntegrationRuntimeArtifacts: fields('Id,Name,Version,Type,Status,DeployedOn'),
|
|
213
|
+
MessageProcessingLogs: fields('MessageGuid,CorrelationId,ApplicationMessageId,IntegrationFlowName,Status,CustomStatus,LogStart,LogEnd'),
|
|
214
|
+
Runs: fields('Id,RunId,MessageGuid,LogStart,LogEnd,Status'),
|
|
215
|
+
MessageProcessingLogRuns: fields('Id,RunId,MessageGuid,LogStart,LogEnd,Status'),
|
|
216
|
+
RunSteps: fields('StepId,ModelStepId,Activity,Status,StepStart,StepStop'),
|
|
217
|
+
CustomHeaderProperties: fields('Name'),
|
|
218
|
+
JmsBrokers: fields('Name,Capacity,MaxCapacity,CapacityOk,CapacityWarning,CapacityError,QueueNumber,MaxQueueNumber,IsQueuesHigh,IsMessageSpoolHigh,IsTransactedSessionsHigh,IsConsumersHigh,IsProducersHigh'),
|
|
219
|
+
QueueStates: fields('Name,State'),
|
|
220
|
+
InactiveQueues: fields('Name,State'),
|
|
221
|
+
Queues: fields('Name,NumbOfMsgs,Size,Type,State,FillGrade,Active,Exclusive'),
|
|
222
|
+
DataStores: fields('DataStoreName,IntegrationFlow,Type,Visibility,NumberOfMessages,NumberOfOverdueMessages'),
|
|
223
|
+
Entries: fields('Id,Status,MessageId,DueAt,CreatedAt,RetainUntil'),
|
|
224
|
+
Variables: fields('Name,IntegrationFlow,Visibility,UpdatedAt,RetainUntil'),
|
|
225
|
+
UserCredentials: fields('Name,Kind,DeployedOn,Status'),
|
|
226
|
+
OAuth2ClientCredentials: fields('Name,Status'),
|
|
227
|
+
KeystoreEntries: fields('Alias,Type,KeyType,ValidNotBefore,ValidNotAfter'),
|
|
228
|
+
NumberRanges: fields('Name,Description,MinValue,MaxValue,CurrentValue,Rotate,FieldLength'),
|
|
229
|
+
StringParameters: fields('Pid,Id'),
|
|
230
|
+
BinaryParameters: fields('Pid,Id,ContentType')
|
|
231
|
+
};
|
|
232
|
+
const APIM_PROJECTORS = {
|
|
233
|
+
APIProviders: fields('name,title,destType,host,port,useSSL,isOnPremise,cloudConnectorLocation,pathPrefix'),
|
|
234
|
+
APIProviderAdditionalPropertys: value => ({ ...pick(value, ['entityId', 'name']), ...(/^(?:sap-client)$/i.test(value?.name ?? '') ? pick(value, ['value']) : {}) }),
|
|
235
|
+
APIProxies: fields('name,title,service_code,state,version,provider_name'),
|
|
236
|
+
APIProducts: fields('name,title,status,scope,quotaCount,quotaInterval,quotaTimeUnit'),
|
|
237
|
+
VirtualHosts: fields('id,name,virtual_host,virtual_port,isSSL,isDefault'),
|
|
238
|
+
KeyMapEntries: fields('name,encrypted,scope'),
|
|
239
|
+
keyMapEntryValues: value => ({ ...pick(value, ['name']), value: REDACTED }),
|
|
240
|
+
KeyMapEntryValues: value => ({ ...pick(value, ['name']), value: REDACTED }),
|
|
241
|
+
Applications: fields('id,title,version,status,status_code'),
|
|
242
|
+
Developers: fields('id,status,status_code'),
|
|
243
|
+
CertificateStoreReferences: fields('id,name,alias,type')
|
|
244
|
+
};
|
|
245
|
+
const CPI_NAVIGATIONS = {
|
|
246
|
+
IntegrationPackages: ['IntegrationDesigntimeArtifacts', 'ValueMappingDesigntimeArtifacts', 'ScriptCollectionDesigntimeArtifacts', 'MessageMappingDesigntimeArtifacts'],
|
|
247
|
+
IntegrationDesigntimeArtifacts: ['Configurations'],
|
|
248
|
+
MessageProcessingLogs: ['Runs', 'CustomHeaderProperties'],
|
|
249
|
+
MessageProcessingLogRuns: ['RunSteps'],
|
|
250
|
+
DataStores: ['Entries'],
|
|
251
|
+
JmsBrokers: ['QueueStates', 'InactiveQueues']
|
|
252
|
+
};
|
|
253
|
+
const APIM_NAVIGATIONS = { KeyMapEntries: ['keyMapEntryValues'] };
|
|
254
|
+
const LOCAL_ROOTS = new Set(['DataStores', 'Entries', 'Variables', 'Queues', 'JmsBrokers', 'QueueStates', 'InactiveQueues', 'NumberRanges', 'UserCredentials', 'OAuth2ClientCredentials', 'KeystoreEntries']);
|
|
255
|
+
const COUNT_ROOTS = new Set(['MessageProcessingLogs', 'IntegrationPackages', 'IntegrationRuntimeArtifacts', 'APIProxies', 'APIProviders', 'APIProducts', 'KeyMapEntries']);
|
|
256
|
+
|
|
257
|
+
function readPolicy(name, path, query) {
|
|
258
|
+
const cpi = name === 'cpi_odata_get';
|
|
259
|
+
const projectors = cpi ? CPI_PROJECTORS : APIM_PROJECTORS;
|
|
260
|
+
const navigations = cpi ? CPI_NAVIGATIONS : APIM_NAVIGATIONS;
|
|
261
|
+
const parts = path.slice(1).split('/');
|
|
262
|
+
const count = parts.at(-1) === '$count';
|
|
263
|
+
if (count) parts.pop();
|
|
264
|
+
if (parts.length > 2) throw new IntegrationError('Only one documented metadata navigation level is allowed');
|
|
265
|
+
const roots = parts.map(part => /^([A-Za-z][A-Za-z0-9_]*)(?:\([^/]*\))?$/.exec(part)?.[1]);
|
|
266
|
+
const [root, navigation] = roots;
|
|
267
|
+
if (!root || !projectors[root] || (navigation && !navigations[root]?.includes(navigation))) {
|
|
268
|
+
throw new IntegrationError('This route has no safe metadata projector; use /$metadata for discovery or a dedicated tool');
|
|
269
|
+
}
|
|
270
|
+
if (count && (navigation || !COUNT_ROOTS.has(root))) throw new IntegrationError('A count contract is not established for this route');
|
|
271
|
+
for (const [option, value] of Object.entries(query)) {
|
|
272
|
+
if (!ODATA_OPTIONS.includes(option) && !(cpi && root === 'KeystoreEntries' && option === 'keystoreName')) throw new IntegrationError(`query option ${option} is not allowed`);
|
|
273
|
+
if (option === '$top' && !/^(?:[1-9]\d?|1\d\d|200)$/.test(value)) throw new IntegrationError('$top must be an integer from 1 to 200');
|
|
274
|
+
if (option === '$skip' && (!/^\d{1,7}$/.test(value) || Number(value) > 1_000_000)) throw new IntegrationError('$skip must be a bounded nonnegative integer');
|
|
275
|
+
if (option === '$inlinecount' && !['allpages', 'none'].includes(value)) throw new IntegrationError('$inlinecount must be allpages or none');
|
|
276
|
+
if (option === 'keystoreName' && !['system', 'backup_admin_system', 'KeyRenewal'].includes(value)) throw new IntegrationError('Unknown keystoreName');
|
|
277
|
+
if (option === '$select' && !/^[A-Za-z][A-Za-z0-9_]*(?:,[A-Za-z][A-Za-z0-9_]*)*$/.test(value)) throw new IntegrationError('$select must contain plain field names');
|
|
278
|
+
if (option === '$expand' && value.split(',').some(entry => !navigations[navigation ?? root]?.includes(entry))) throw new IntegrationError('$expand is not an allowed metadata navigation');
|
|
279
|
+
if (option === '$orderby' && !/^[A-Za-z][A-Za-z0-9_]*(?: (?:asc|desc))?$/.test(value)) throw new IntegrationError('$orderby must contain one plain field');
|
|
280
|
+
}
|
|
281
|
+
const selected = navigation ?? root;
|
|
282
|
+
const project = value => {
|
|
283
|
+
const result = projectors[selected](value);
|
|
284
|
+
for (const child of (query.$expand ?? '').split(',').filter(Boolean)) {
|
|
285
|
+
const raw = value?.[child];
|
|
286
|
+
if (raw !== undefined) result[child] = list(raw).map(projectors[child]);
|
|
287
|
+
}
|
|
288
|
+
return redactSecrets(withoutSecrets(result));
|
|
289
|
+
};
|
|
290
|
+
return { project, collection: parts.length === 2 || !parts[0].includes('('), mode: LOCAL_ROOTS.has(selected) ? 'local' : 'odata', count };
|
|
291
|
+
}
|
|
292
|
+
|
|
186
293
|
/** Summarize an OData $metadata document into entity sets and function imports. */
|
|
187
294
|
export function metadataSummary(xml) {
|
|
188
295
|
const attribute = (tag, name) => tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
|
|
@@ -195,38 +302,36 @@ export function metadataSummary(xml) {
|
|
|
195
302
|
return { entity_sets: entitySets, function_imports: functionImports };
|
|
196
303
|
}
|
|
197
304
|
|
|
198
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
*/
|
|
202
|
-
export function registerODataReader(register, { name, title, service, request, examples }) {
|
|
305
|
+
/** A metadata-only, versioned route allowlist; never an arbitrary GET passthrough. */
|
|
306
|
+
export function registerODataReader(register, { name, title, service, request, client, root, examples }) {
|
|
307
|
+
const pager = createCollectionPager(client ?? request, { root, request });
|
|
203
308
|
register(name, title,
|
|
204
|
-
`Read
|
|
309
|
+
`Read allowlisted operational metadata from ${service}. Discover entity/path names with /$metadata; only implemented safe projectors authorize reads. No function imports, payloads, source, arbitrary expansions, configuration values or secrets. Examples: ${examples}. Collections use signed bounded cursors.`,
|
|
205
310
|
{
|
|
206
|
-
path: z.string().max(1000).regex(ODATA_PATH, 'must be /$metadata or
|
|
311
|
+
path: z.string().max(1000).regex(ODATA_PATH, 'must be /$metadata or a metadata entity path without query string or media')
|
|
207
312
|
.refine(value => !value.includes('..'), 'must not contain ..'),
|
|
208
|
-
query: z.record(z.string().
|
|
209
|
-
|
|
313
|
+
query: z.record(z.string().max(50), z.string().max(2000)).optional(),
|
|
314
|
+
top: top(),
|
|
315
|
+
cursor: cursorSchema
|
|
210
316
|
},
|
|
211
317
|
READ_ONLY_ANNOTATIONS,
|
|
212
|
-
async ({ path, query = {} }) => {
|
|
213
|
-
for (const option of Object.keys(query)) {
|
|
214
|
-
if (option.startsWith('$') && !ODATA_OPTIONS.includes(option)) throw new IntegrationError(`query option ${option} is not allowed`);
|
|
215
|
-
}
|
|
318
|
+
async ({ path, query = {}, top: pageSize, cursor }) => {
|
|
216
319
|
if (path === '/$metadata') {
|
|
320
|
+
if (cursor || Object.keys(query).length) throw new IntegrationError('Metadata discovery accepts no cursor or query options');
|
|
217
321
|
return metadataSummary(await request('GET', path, { accept: 'application/xml', responseType: 'text' }));
|
|
218
322
|
}
|
|
219
|
-
|
|
323
|
+
const policy = readPolicy(name, path, query);
|
|
324
|
+
if (policy.count) {
|
|
325
|
+
if (cursor) throw new IntegrationError('Count reads accept no cursor');
|
|
220
326
|
const text = await request('GET', path, { query, accept: 'text/plain', responseType: 'text' });
|
|
221
|
-
|
|
327
|
+
const trimmed = String(text).trim();
|
|
328
|
+
if (!/^\d+$/.test(trimmed) || !Number.isSafeInteger(Number(trimmed))) throw new IntegrationError('Count endpoint returned invalid nonnegative integer text');
|
|
329
|
+
return { count: Number(trimmed) };
|
|
330
|
+
}
|
|
331
|
+
if (policy.collection) {
|
|
332
|
+
return pager.page({ path, query, top: Number(query.$top ?? pageSize), cursor, project: policy.project, mode: policy.mode, orderBy: query.$orderby });
|
|
222
333
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const total = Number(data?.__count);
|
|
226
|
-
const results = Array.isArray(data?.results) ? data.results : null;
|
|
227
|
-
const cleaned = redactSecrets(entity(results ?? data));
|
|
228
|
-
return results
|
|
229
|
-
? { total: Number.isFinite(total) ? total : null, returned: results.length, more: Boolean(data?.__next), results: cleaned }
|
|
230
|
-
: cleaned;
|
|
334
|
+
if (cursor) throw new IntegrationError('Named metadata reads accept no cursor');
|
|
335
|
+
return policy.project(await request('GET', path, { query }));
|
|
231
336
|
});
|
|
232
337
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// Resolves
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// Resolves core OAuth credentials and independently configured optional services.
|
|
2
|
+
// Pure: no I/O, no network. Stable error prefixes allow the tool layer to
|
|
3
|
+
// surface configuration failures without echoing secret values.
|
|
4
|
+
import { OPTIONAL_COMPONENT_KEYS, resolveOptionalConfig } from './integration-optional-config.mjs';
|
|
5
|
+
|
|
6
6
|
|
|
7
7
|
const SIDES = Object.freeze({
|
|
8
8
|
CPI: Object.freeze({ prefix: 'CPI', defaultVcapService: 'it-rt' }),
|
|
9
|
-
APIM: Object.freeze({ prefix: 'APIM', defaultVcapService: 'apimanagement-apiportal' })
|
|
9
|
+
APIM: Object.freeze({ prefix: 'APIM', defaultVcapService: 'apimanagement-apiportal' }),
|
|
10
|
+
DEVHUB: Object.freeze({ prefix: 'DEVHUB', defaultVcapService: 'apimanagement-devportal' })
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
@@ -65,7 +66,7 @@ function explicitConfig(env, prefix) {
|
|
|
65
66
|
});
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
/** Parse
|
|
69
|
+
/** Parse an Integration Suite service key (nested `oauth` or flat shape) into raw OAuth fields. */
|
|
69
70
|
function parseServiceKey(value, origin) {
|
|
70
71
|
let key = value;
|
|
71
72
|
if (typeof value === 'string') {
|
|
@@ -146,22 +147,27 @@ export function resolveIntegrationSideFromEnvironment(env, side) {
|
|
|
146
147
|
return resolveSide(env, SIDES[side]);
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
/** Validate a pasted
|
|
150
|
+
/** Validate a pasted Integration Suite service key (JSON text or object); returns the resolved side config. */
|
|
150
151
|
export function parseIntegrationServiceKey(side, value) {
|
|
151
152
|
return finalize(side, 'stored-key', null, parseServiceKey(value, `${side} service key`));
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
/**
|
|
155
|
-
* Resolve
|
|
156
|
-
* `stored` holds
|
|
156
|
+
* Resolve core and optional credentials; absent configuration retains setup guidance.
|
|
157
|
+
* `stored` holds setup wizard keys with lowest precedence.
|
|
157
158
|
*/
|
|
158
159
|
export function resolveIntegrationConfig(env = process.env, { stored } = {}) {
|
|
159
160
|
const cpi = resolveSide(env, SIDES.CPI, stored);
|
|
160
161
|
const apim = resolveSide(env, SIDES.APIM, stored);
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
const devhub = resolveSide(env, SIDES.DEVHUB, stored);
|
|
163
|
+
const optional = resolveOptionalConfig(env, stored);
|
|
164
|
+
if (!cpi && !apim && !devhub && !OPTIONAL_COMPONENT_KEYS.some(side => optional[side]) && !optional.optional_config_errors.length) {
|
|
165
|
+
fail('Configure CPI (CPI_SERVICE_KEY or CPI_URL/CPI_TOKEN_URL/CPI_CLIENT_ID/CPI_CLIENT_SECRET), APIM (APIM_SERVICE_KEY or APIM_*), and/or DEVHUB (DEVHUB_SERVICE_KEY or DEVHUB_URL/DEVHUB_TOKEN_URL/DEVHUB_CLIENT_ID/DEVHUB_CLIENT_SECRET) credentials in the MCP host environment, or run sap-ai-dev --setup-integration to paste and verify service keys.');
|
|
163
166
|
}
|
|
164
|
-
|
|
167
|
+
const environment = typeof env.INTEGRATION_ENVIRONMENT === 'string' && env.INTEGRATION_ENVIRONMENT.trim()
|
|
168
|
+
? env.INTEGRATION_ENVIRONMENT.trim()
|
|
169
|
+
: 'default';
|
|
170
|
+
return Object.freeze({ cpi, apim, devhub, ...optional, environment });
|
|
165
171
|
}
|
|
166
172
|
|
|
167
|
-
export const INTEGRATION_CONFIG_ERROR = /^(?:Explicit CPI|
|
|
173
|
+
export const INTEGRATION_CONFIG_ERROR = /^(?:Explicit (?:CPI|APIM|DEVHUB)|(?:CPI|APIM|DEVHUB)_|No (?:CPI|APIM|DEVHUB)|Multiple (?:CPI|APIM|DEVHUB)|Configure CPI|Stored (?:CPI|APIM|DEVHUB)|Stored Integration Suite)/;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// User-only store for
|
|
2
|
-
// The file lives outside mcp.json (~/.config/sap-ai-dev-toolkit/integration-suite.json, mode 0600)
|
|
3
|
-
//
|
|
1
|
+
// User-only store for core service keys and normalized optional-service credentials.
|
|
2
|
+
// The file lives outside mcp.json (~/.config/sap-ai-dev-toolkit/integration-suite.json, mode 0600).
|
|
3
|
+
// Environment credentials take precedence; source metadata is never stored.
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { OPTIONAL_COMPONENT_KEYS, optionalConfigRecord } from './integration-optional-config.mjs';
|
|
9
10
|
|
|
10
|
-
const SIDES = ['cpi', 'apim'];
|
|
11
|
+
const SIDES = ['cpi', 'apim', 'devhub', ...OPTIONAL_COMPONENT_KEYS];
|
|
11
12
|
|
|
12
13
|
export function integrationCredentialsPath(env = process.env) {
|
|
13
14
|
const configHome = typeof env.XDG_CONFIG_HOME === 'string' && env.XDG_CONFIG_HOME.trim()
|
|
@@ -16,7 +17,7 @@ export function integrationCredentialsPath(env = process.env) {
|
|
|
16
17
|
return join(configHome, 'sap-ai-dev-toolkit', 'integration-suite.json');
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
/** Stored
|
|
20
|
+
/** Stored keys/normalized optional records; malformed optional records remain isolated by the resolver. */
|
|
20
21
|
export function readStoredIntegrationKeys(env = process.env) {
|
|
21
22
|
const path = integrationCredentialsPath(env);
|
|
22
23
|
let text;
|
|
@@ -32,23 +33,28 @@ export function readStoredIntegrationKeys(env = process.env) {
|
|
|
32
33
|
} catch {
|
|
33
34
|
throw new Error(`Stored Integration Suite credentials at ${path} are not valid JSON; rerun sap-ai-dev --setup-integration`);
|
|
34
35
|
}
|
|
35
|
-
return Object.fromEntries(SIDES.filter(side => parsed
|
|
36
|
+
return Object.fromEntries(SIDES.filter(side => Object.hasOwn(parsed || {}, side)).map(side => [side, parsed[side]]));
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/** Save one side
|
|
39
|
+
/** Save one side alongside other stored keys; optional records contain only typed normalized fields. */
|
|
39
40
|
export async function storeIntegrationKey(side, config, env = process.env) {
|
|
40
41
|
const key = side.toLowerCase();
|
|
41
42
|
if (!SIDES.includes(key)) throw new Error(`Unknown Integration Suite side ${side}`);
|
|
43
|
+
const optional = OPTIONAL_COMPONENT_KEYS.includes(key);
|
|
44
|
+
const record = optional ? optionalConfigRecord(key, config) : {
|
|
45
|
+
oauth: { url: config.url, tokenurl: config.tokenUrl, clientid: config.clientId, clientsecret: config.clientSecret }
|
|
46
|
+
};
|
|
42
47
|
const path = integrationCredentialsPath(env);
|
|
43
48
|
let existing = {};
|
|
44
49
|
try {
|
|
45
50
|
existing = readStoredIntegrationKeys(env);
|
|
46
|
-
} catch {
|
|
47
|
-
//
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// Optional setup never replaces a store it could not read; core setup behavior is unchanged.
|
|
53
|
+
if (optional) throw error;
|
|
48
54
|
}
|
|
49
55
|
const next = {
|
|
50
56
|
...existing,
|
|
51
|
-
[key]:
|
|
57
|
+
[key]: record
|
|
52
58
|
};
|
|
53
59
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
54
60
|
await chmod(dirname(path), 0o700).catch(() => {});
|