sap-ai-dev-toolkit 0.9.9918 → 0.9.9920
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +48 -56
- 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 +66 -44
- package/tools.md +193 -26
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// Consumer retirement never cascades to products, proxies, developers or runtime credentials.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { IntegrationError } from './integration-http.mjs';
|
|
5
|
+
import { entity, key, OVERWRITE_ANNOTATIONS, READ_ONLY_ANNOTATIONS, requireConfirmation, sleep, top } from './integration-common.mjs';
|
|
6
|
+
import { createCollectionPager, cursorSchema, decodeCollection } from './integration-collections.mjs';
|
|
7
|
+
import { canonicalDigest } from './integration-state.mjs';
|
|
8
|
+
import { redactPrivateValues } from './apim-runtime.mjs';
|
|
9
|
+
|
|
10
|
+
const DATA = '/odata/1.0/data.svc';
|
|
11
|
+
const PREVIEW_TTL = 15 * 60 * 1000;
|
|
12
|
+
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
const MAX_RELATIONSHIPS = 2000;
|
|
14
|
+
const applicationId = z.string().min(1).max(256).regex(/^[A-Za-z0-9_-]+$/);
|
|
15
|
+
const productName = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
|
|
16
|
+
const confirmation = z.string().min(1).max(512).optional();
|
|
17
|
+
const idValue = value => typeof value === 'string' && /^[A-Za-z0-9_.-]{1,256}$/.test(value) ? value : null;
|
|
18
|
+
const subscribed = value => value === true || value === 'true' ? true : value === false || value === 'false' ? false : null;
|
|
19
|
+
const statusValue = value => typeof value === 'string' && /^[A-Za-z0-9 _-]{1,64}$/.test(value) ? value : null;
|
|
20
|
+
const applicationPath = id => `/APIMgmt.Applications(${key(id)})`;
|
|
21
|
+
const subscriptionPath = id => `/APIMgmt.Subscriptions(${key(id)})`;
|
|
22
|
+
const identityFor = (row, navigation) => navigation === 'ToSubscriptions' ? idValue(row?.id) : idValue(row?.product_id ?? row?.name ?? row?.id);
|
|
23
|
+
const compare = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
24
|
+
const failure = error => ({ result: 'error', ...(Number.isInteger(error?.status) ? { status: error.status } : {}) });
|
|
25
|
+
const credentialEffects = Object.freeze({ api_key_invalidation: 'unverified', issued_oauth_token_revocation: 'unverified' });
|
|
26
|
+
|
|
27
|
+
function privateValues(value, values = []) {
|
|
28
|
+
if (!value || typeof value !== 'object') return values;
|
|
29
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
30
|
+
if (/^(?:app_key|app_secret|consumer_?key|consumer_?secret|access_token|refresh_token|password)$/i.test(name) && typeof entry === 'string' && entry) values.push(entry);
|
|
31
|
+
else if (entry && typeof entry === 'object') privateValues(entry, values);
|
|
32
|
+
}
|
|
33
|
+
return values;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function subscriptionSummary(value, application) {
|
|
37
|
+
return {
|
|
38
|
+
subscription_id: idValue(value?.id), application_id: idValue(value?.app_id) ?? application,
|
|
39
|
+
product_id: idValue(value?.product_id), rate_plan_id: idValue(value?.ratePlan_id),
|
|
40
|
+
is_subscribed: subscribed(value?.isSubscribed), approval_status: statusValue(value?.status),
|
|
41
|
+
...(value?.status_code !== undefined ? { approval_status_code: statusValue(String(value.status_code)) } : {})
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function relationPage(value) {
|
|
46
|
+
try {
|
|
47
|
+
const decoded = decodeCollection(value);
|
|
48
|
+
if (!value || value.__deferred) return null;
|
|
49
|
+
const source = value.d ?? value;
|
|
50
|
+
const count = source.__count ?? source['@odata.count'] ?? source['@count'];
|
|
51
|
+
if (count !== undefined && decoded.total === null) return null;
|
|
52
|
+
if (decoded.items.some(row => !row || typeof row !== 'object' || Array.isArray(row))) return null;
|
|
53
|
+
return decoded;
|
|
54
|
+
} catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function referenceEvidence(value, path = '', references = []) {
|
|
58
|
+
if (!value || typeof value !== 'object') return references;
|
|
59
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
60
|
+
if (name === '__metadata' && typeof entry?.uri === 'string') references.push([path, entry.uri]);
|
|
61
|
+
else if (entry && typeof entry === 'object') referenceEvidence(entry, `${path}/${name}`, references);
|
|
62
|
+
}
|
|
63
|
+
return references;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Register with a null client for tool-name enumeration; all client access stays in handlers. */
|
|
67
|
+
export function registerDevhubRetirementTools(register, client, { readBackDelayMs = 5000 } = {}) {
|
|
68
|
+
const previews = new Map();
|
|
69
|
+
const request = (method, path, options) => client.request(method, `${DATA}${path}`, options);
|
|
70
|
+
const origin = () => {
|
|
71
|
+
if (!client || typeof client.origin !== 'string') throw new IntegrationError('Developer Hub has no pinned origin');
|
|
72
|
+
return new URL(client.origin).origin;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
async function namedApplication(id, expanded = true) {
|
|
76
|
+
const response = await request('GET', applicationPath(id), {
|
|
77
|
+
...(expanded ? { query: { $expand: 'ToAPIProductsDetails,ToSubscriptions/ToAPIProduct,ToSubscriptions/ToRatePlan' } } : {}),
|
|
78
|
+
expect: [200, 404], withStatus: true, unwrap: false
|
|
79
|
+
});
|
|
80
|
+
if (response.status === 404) return null;
|
|
81
|
+
const value = response.data?.d ?? response.data;
|
|
82
|
+
if (!value || value.id !== id) throw new IntegrationError('Cannot establish the requested consumer application identity');
|
|
83
|
+
if (Buffer.byteLength(JSON.stringify(value)) > MAX_SOURCE_BYTES) throw new IntegrationError('Consumer application relationships exceed the safe source limit');
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Follow only a supplied same-navigation continuation, never invent another endpoint/query shape.
|
|
88
|
+
async function inventoryRelationship(application, navigation) {
|
|
89
|
+
let raw = application[navigation];
|
|
90
|
+
const rows = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const links = new Set();
|
|
93
|
+
let expected = null;
|
|
94
|
+
let bytes = 0;
|
|
95
|
+
const path = `${applicationPath(application.id)}/${navigation}`;
|
|
96
|
+
for (let page = 0; page < 256; page += 1) {
|
|
97
|
+
const decoded = relationPage(raw);
|
|
98
|
+
if (!decoded) return { rows, complete: false, stop_reason: 'unknown_relationship_shape' };
|
|
99
|
+
bytes += Buffer.byteLength(JSON.stringify(raw));
|
|
100
|
+
if (bytes > MAX_SOURCE_BYTES || rows.length + decoded.items.length > MAX_RELATIONSHIPS) return { rows, complete: false, stop_reason: 'source_limit' };
|
|
101
|
+
if (decoded.total !== null) {
|
|
102
|
+
if (expected !== null && expected !== decoded.total) return { rows, complete: false, stop_reason: 'source_changed' };
|
|
103
|
+
expected = decoded.total;
|
|
104
|
+
}
|
|
105
|
+
for (const row of decoded.items) {
|
|
106
|
+
const id = identityFor(row, navigation);
|
|
107
|
+
if (!id || seen.has(id) || navigation === 'ToSubscriptions' && (!idValue(row.product_id) || row.app_id !== undefined && row.app_id !== application.id)) {
|
|
108
|
+
return { rows, complete: false, stop_reason: 'ambiguous_relationship' };
|
|
109
|
+
}
|
|
110
|
+
seen.add(id);
|
|
111
|
+
rows.push(row);
|
|
112
|
+
}
|
|
113
|
+
if (!decoded.next) return { rows, complete: expected === null || expected === rows.length, stop_reason: expected !== null && expected !== rows.length ? 'source_limit' : 'end' };
|
|
114
|
+
let next;
|
|
115
|
+
try { next = new URL(decoded.next, `${origin()}${DATA}${path}`); } catch { return { rows, complete: false, stop_reason: 'invalid_continuation' }; }
|
|
116
|
+
if (next.origin !== origin() || next.username || next.password || next.hash || decodeURIComponent(next.pathname) !== decodeURIComponent(`${DATA}${path}`)
|
|
117
|
+
|| [...next.searchParams.keys()].some(name => !['$skip', '$skiptoken', '$top', '$inlinecount', '$count', '$format', '$expand', '$orderby'].includes(name) || next.searchParams.getAll(name).length !== 1)
|
|
118
|
+
|| links.has(next.href)) return { rows, complete: false, stop_reason: 'invalid_continuation' };
|
|
119
|
+
links.add(next.href);
|
|
120
|
+
try { raw = await request('GET', path, { query: Object.fromEntries(next.searchParams), unwrap: false }); }
|
|
121
|
+
catch (error) { return { rows, complete: false, stop_reason: 'source_error', source_result: failure(error) }; }
|
|
122
|
+
}
|
|
123
|
+
return { rows, complete: false, stop_reason: 'source_limit' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function inspect(id) {
|
|
127
|
+
const application = await namedApplication(id);
|
|
128
|
+
if (!application) return null;
|
|
129
|
+
const subscriptions = await inventoryRelationship(application, 'ToSubscriptions');
|
|
130
|
+
const legacy = await inventoryRelationship(application, 'ToAPIProductsDetails');
|
|
131
|
+
const owner = { developer_id: application.developer_id ?? null, reg_id: application.reg_id ?? null };
|
|
132
|
+
const ownerKnown = Object.values(owner).some(value => typeof value === 'string' && value.length > 0);
|
|
133
|
+
const statesKnown = subscriptions.rows.every(row => subscribed(row.isSubscribed) !== null);
|
|
134
|
+
const complete = ownerKnown && subscriptions.complete && legacy.complete && statesKnown;
|
|
135
|
+
const sortRows = (rows, navigation) => [...rows].sort((a, b) => compare(identityFor(a, navigation), identityFor(b, navigation)))
|
|
136
|
+
.map(row => ({ value: entity(row), relationship_references: referenceEvidence(row) }));
|
|
137
|
+
return {
|
|
138
|
+
application, subscriptions, legacy, complete, owner,
|
|
139
|
+
evidence: { owner, subscriptions: sortRows(subscriptions.rows, 'ToSubscriptions'), legacy_product_links: sortRows(legacy.rows, 'ToAPIProductsDetails') },
|
|
140
|
+
private_values: privateValues(application).concat(privateValues(subscriptions.rows), privateValues(legacy.rows)),
|
|
141
|
+
findings: [
|
|
142
|
+
...(!ownerKnown ? ['owner_unknown'] : []), ...(!statesKnown ? ['subscription_state_unknown'] : []),
|
|
143
|
+
...(!subscriptions.complete ? [`subscriptions_${subscriptions.stop_reason}`] : []),
|
|
144
|
+
...(!legacy.complete ? [`legacy_products_${legacy.stop_reason}`] : [])
|
|
145
|
+
]
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function subscriptionPage(application) {
|
|
150
|
+
const page = relationPage(application.ToSubscriptions);
|
|
151
|
+
if (!page) throw new IntegrationError('Consumer subscription relationships are incomplete or unsupported');
|
|
152
|
+
const secrets = privateValues(application);
|
|
153
|
+
return {
|
|
154
|
+
results: page.items.map(row => redactPrivateValues(subscriptionSummary(row, application.id), secrets)),
|
|
155
|
+
...(page.total === null ? {} : { __count: String(page.total) }), ...(page.next ? { __next: page.next } : {})
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A local page re-reads exactly the documented expanded application; signed cursors detect drift.
|
|
160
|
+
const collections = createCollectionPager(client, {
|
|
161
|
+
root: DATA,
|
|
162
|
+
request: async (_method, path) => {
|
|
163
|
+
const match = /^\/APIMgmt\.Applications\('([A-Za-z0-9_-]+)'\)\/ToSubscriptions$/.exec(decodeURIComponent(path));
|
|
164
|
+
if (!match) throw new IntegrationError('Invalid consumer subscription listing');
|
|
165
|
+
const application = await namedApplication(match[1]);
|
|
166
|
+
if (!application) throw new IntegrationError('Consumer application does not exist');
|
|
167
|
+
return subscriptionPage(application);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
function reference(value, collection, expectedId, { nullable = false, scalar } = {}) {
|
|
172
|
+
const page = value === null ? { items: [], total: null, next: null } : Array.isArray(value) || value?.results || value?.value ? relationPage(value)
|
|
173
|
+
: value && typeof value === 'object' && !value.__deferred ? { items: [value], total: null, next: null } : null;
|
|
174
|
+
if (nullable && (!page && value === undefined && (scalar === null || scalar === '') || page && page.items.length === 0 && !page.next && (page.total === null || page.total === 0) && (scalar === undefined || scalar === null || scalar === ''))) return null;
|
|
175
|
+
if (!page || page.next || page.items.length !== 1 || page.total !== null && page.total !== 1) throw new IntegrationError('Subscription relationship references are incomplete or ambiguous; unsubscribe refused');
|
|
176
|
+
const uri = page.items[0]?.__metadata?.uri;
|
|
177
|
+
if (typeof uri !== 'string' || /[\x00-\x20\x7f\\]/.test(uri)) throw new IntegrationError('Subscription relationship has no authoritative reference; unsubscribe refused');
|
|
178
|
+
let url;
|
|
179
|
+
try { url = new URL(uri, `${origin()}${DATA}/`); } catch { throw new IntegrationError('Invalid subscription relationship reference'); }
|
|
180
|
+
if (url.origin !== origin() || url.username || url.password || url.search || url.hash) throw new IntegrationError('Subscription relationship leaves the pinned service');
|
|
181
|
+
const match = /^\/odata\/1\.0\/data\.svc\/APIMgmt\.(APIProducts|Applications|RatePlans)\('((?:[^']|'')*)'\)$/.exec(decodeURIComponent(url.pathname));
|
|
182
|
+
const id = match?.[2]?.replaceAll("''", "'");
|
|
183
|
+
if (match?.[1] !== collection || !idValue(id) || expectedId !== undefined && id !== expectedId || scalar && id !== scalar) throw new IntegrationError('Subscription relationship identity does not match its scalar IDs');
|
|
184
|
+
return [{ __metadata: { uri } }];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function namedSubscription(id, application, product) {
|
|
188
|
+
const response = await request('GET', subscriptionPath(id), {
|
|
189
|
+
query: { $expand: 'ToAPIProduct,ToApplication,ToRatePlan' }, expect: [200, 404], withStatus: true, unwrap: false
|
|
190
|
+
});
|
|
191
|
+
if (response.status === 404) throw new IntegrationError('The selected subscription no longer exists');
|
|
192
|
+
const value = response.data?.d ?? response.data;
|
|
193
|
+
if (value?.id !== id || value.app_id !== application || value.product_id !== product || typeof value.developer_id !== 'string' || !value.developer_id || subscribed(value.isSubscribed) === null) {
|
|
194
|
+
throw new IntegrationError('Cannot establish the exact subscription and owner identities; unsubscribe refused');
|
|
195
|
+
}
|
|
196
|
+
const body = { id, app_id: value.app_id, product_id: value.product_id, developer_id: value.developer_id, isSubscribed: false };
|
|
197
|
+
for (const field of ['reg_id', 'ratePlan_id']) if (value[field] !== undefined) body[field] = value[field];
|
|
198
|
+
body.ToAPIProduct = reference(value.ToAPIProduct, 'APIProducts', product);
|
|
199
|
+
body.ToApplication = reference(value.ToApplication, 'Applications', application);
|
|
200
|
+
const ratePlan = reference(value.ToRatePlan, 'RatePlans', undefined, { nullable: true, scalar: value.ratePlan_id });
|
|
201
|
+
if (ratePlan) body.ToRatePlan = ratePlan;
|
|
202
|
+
return { value, body };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
register('devhub_list_subscriptions', 'List consumer application subscriptions',
|
|
206
|
+
'List bounded subscription/application/product/rate-plan identities and subscribed/approval state from the expanded consumer application. No credentials or developer personal details are returned; incomplete relationships remain explicit.',
|
|
207
|
+
z.object({ application_id: applicationId, top: top(), cursor: cursorSchema }).strict(), READ_ONLY_ANNOTATIONS,
|
|
208
|
+
async ({ application_id, cursor, ...args }) => {
|
|
209
|
+
const scope = 'devhub_subscriptions';
|
|
210
|
+
if (cursor && collections.cursorContext(cursor, { scope })?.application_id !== application_id) throw new IntegrationError('Subscription cursor belongs to a different application');
|
|
211
|
+
const application = await namedApplication(application_id);
|
|
212
|
+
if (!application) throw new IntegrationError('Consumer application does not exist');
|
|
213
|
+
const legacy = relationPage(application.ToAPIProductsDetails);
|
|
214
|
+
const subscriptions = relationPage(application.ToSubscriptions);
|
|
215
|
+
const page = await collections.page({
|
|
216
|
+
...args, cursor, path: `${applicationPath(application_id)}/ToSubscriptions`, mode: 'local',
|
|
217
|
+
raw: subscriptionPage(application), scope, context: { application_id }
|
|
218
|
+
});
|
|
219
|
+
const legacyComplete = Boolean(legacy && !legacy.next && (legacy.total === null || legacy.total === legacy.items.length));
|
|
220
|
+
const model = !legacyComplete ? 'unknown' : legacy.items.length
|
|
221
|
+
? subscriptions?.items.length ? 'mixed' : 'legacy' : 'subscription';
|
|
222
|
+
return {
|
|
223
|
+
...page, model, legacy_product_link_count: legacy?.total ?? (legacyComplete ? legacy.items.length : null),
|
|
224
|
+
legacy_relationships_complete: legacyComplete, complete: page.complete && legacyComplete,
|
|
225
|
+
...(!legacyComplete && page.stop_reason === 'end' ? { stop_reason: 'source_limit' } : {})
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
register('devhub_unsubscribe_application', 'Unsubscribe application from a product',
|
|
230
|
+
'Preview by default. Execute one documented batch PUT for the exact existing subscription, preserving its actual relationship references; legacy or ambiguous models are refused. Requires UNSUBSCRIBE APPLICATION <application_id> FROM <product> before requests. Fresh isSubscribed:false read-back is required; this does not prove API-key invalidation or OAuth-token revocation.',
|
|
231
|
+
z.object({ application_id: applicationId, product: productName, dry_run: z.boolean().default(true), confirmation }).strict(), OVERWRITE_ANNOTATIONS,
|
|
232
|
+
async ({ application_id, product, dry_run = true, confirmation: approved }) => {
|
|
233
|
+
const required = `UNSUBSCRIBE APPLICATION ${application_id} FROM ${product}`;
|
|
234
|
+
if (!dry_run) requireConfirmation(approved, required);
|
|
235
|
+
const inventory = await inspect(application_id);
|
|
236
|
+
if (!inventory) throw new IntegrationError('Consumer application does not exist');
|
|
237
|
+
if (!inventory.complete) throw new IntegrationError('Cannot establish complete consumer relationships and owner; unsubscribe refused');
|
|
238
|
+
if (inventory.legacy.rows.length) throw new IntegrationError('Legacy product links are present; mixing subscription models is refused');
|
|
239
|
+
const matches = inventory.subscriptions.rows.filter(row => row.product_id === product);
|
|
240
|
+
if (matches.length !== 1) throw new IntegrationError('Expected exactly one existing subscription for the selected product; unsubscribe refused');
|
|
241
|
+
const subscription = await namedSubscription(matches[0].id, application_id, product);
|
|
242
|
+
if (inventory.owner.developer_id && inventory.owner.developer_id !== subscription.value.developer_id) throw new IntegrationError('Subscription owner differs from the application owner; unsubscribe refused');
|
|
243
|
+
const secrets = inventory.private_values.concat(privateValues(subscription.value));
|
|
244
|
+
const base = { application_id, product, subscription_id: subscription.value.id, ...credentialEffects };
|
|
245
|
+
if (dry_run) return redactPrivateValues({ ...base, dry_run: true, complete: true, is_subscribed: subscribed(subscription.value.isSubscribed), required_confirmation: required,
|
|
246
|
+
impact: 'Set only the selected existing subscription to isSubscribed:false; retain the application and every other subscription.' }, secrets);
|
|
247
|
+
if (subscribed(subscription.value.isSubscribed) === false) return redactPrivateValues({ ...base, accepted: false, already_unsubscribed: true, unsubscribed: true, read_back_verified: true }, secrets);
|
|
248
|
+
const intendedDigest = canonicalDigest({ body: subscription.body, relationship_references: referenceEvidence(subscription.body) });
|
|
249
|
+
const batch = `batch_${randomUUID()}`;
|
|
250
|
+
const changeset = `changeset_${randomUUID()}`;
|
|
251
|
+
const payload = [
|
|
252
|
+
`--${batch}`, `Content-Type: multipart/mixed; boundary=${changeset}`, '', `--${changeset}`,
|
|
253
|
+
'Content-Type: application/http', 'Content-Transfer-Encoding: binary', 'Content-ID: 1', '',
|
|
254
|
+
`PUT APIMgmt.Subscriptions(${key(subscription.value.id)}) HTTP/1.1`, 'Content-Type: application/json', 'Accept: application/json', '',
|
|
255
|
+
JSON.stringify(subscription.body), `--${changeset}--`, `--${batch}--`, ''
|
|
256
|
+
].join('\r\n');
|
|
257
|
+
let response;
|
|
258
|
+
try { response = await request('POST', '/$batch', { body: payload, contentType: `multipart/mixed; boundary=${batch}`, accept: 'multipart/mixed', responseType: 'text', expect: [200, 202], withStatus: true }); }
|
|
259
|
+
catch (error) { return redactPrivateValues({ ...base, accepted: Number.isInteger(error?.status) ? false : null, unsubscribed: false, read_back_verified: false, outcome: 'unverified', source_result: failure(error) }, secrets); }
|
|
260
|
+
const statuses = [...String(response.data).matchAll(/(?:^|\r?\n)HTTP\/1\.[01] (\d{3})(?:[ \r\n])/g)].map(match => Number(match[1]));
|
|
261
|
+
const result = { ...base, batch_accepted: true, http_status: response.status, member_statuses: statuses.slice(0, 20), member_count: statuses.length,
|
|
262
|
+
member_statuses_truncated: statuses.length > 20, accepted: statuses.length === 1 ? statuses[0] >= 200 && statuses[0] < 300 : null,
|
|
263
|
+
unsubscribed: false, read_back_verified: false, outcome: 'unverified' };
|
|
264
|
+
if (result.accepted !== true) return redactPrivateValues({ ...result, ...(result.accepted === false ? { outcome: 'failed' } : {}) }, secrets);
|
|
265
|
+
const inner = String(response.data).slice(String(response.data).search(/HTTP\/1\.[01] /));
|
|
266
|
+
const headerEnd = /\r?\n\r?\n/.exec(inner);
|
|
267
|
+
if (!headerEnd) return redactPrivateValues(result, secrets);
|
|
268
|
+
const embeddedBody = inner.slice(headerEnd.index + headerEnd[0].length).split(/\r?\n--/)[0].trim();
|
|
269
|
+
if (embeddedBody) {
|
|
270
|
+
let decoded;
|
|
271
|
+
try { decoded = JSON.parse(embeddedBody); } catch { return redactPrivateValues(result, secrets); }
|
|
272
|
+
if (decoded?.error) return redactPrivateValues({ ...result, accepted: false, outcome: 'failed' }, secrets);
|
|
273
|
+
if (!decoded || typeof decoded !== 'object' || !(decoded.d || decoded.id)) return redactPrivateValues(result, secrets);
|
|
274
|
+
}
|
|
275
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
276
|
+
if (attempt) await sleep(readBackDelayMs);
|
|
277
|
+
try {
|
|
278
|
+
const observed = await namedSubscription(subscription.value.id, application_id, product);
|
|
279
|
+
secrets.push(...privateValues(observed.value));
|
|
280
|
+
if (subscribed(observed.value.isSubscribed) === false && canonicalDigest({ body: observed.body, relationship_references: referenceEvidence(observed.body) }) === intendedDigest) {
|
|
281
|
+
return redactPrivateValues({ ...result, unsubscribed: true, read_back_verified: true, outcome: 'succeeded' }, secrets);
|
|
282
|
+
}
|
|
283
|
+
} catch (error) { return redactPrivateValues({ ...result, source_result: failure(error) }, secrets); }
|
|
284
|
+
}
|
|
285
|
+
return redactPrivateValues(result, secrets);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
register('devhub_delete_application', 'Delete consumer application',
|
|
289
|
+
'Preview by default; cache the complete owner/subscription/legacy-link approval for 15 minutes. Active subscriptions or any legacy product links require separately approved allow_active_subscriptions:true. Execute requires DELETE CONSUMER APPLICATION <SHA256> matching that cached preview before requests, then fresh equal relationships. DELETE once and verify named absence; API-key invalidation and issued OAuth-token revocation remain unverified.',
|
|
290
|
+
z.object({ application_id: applicationId, dry_run: z.boolean().default(true), allow_active_subscriptions: z.boolean().default(false), confirmation }).strict(), OVERWRITE_ANNOTATIONS,
|
|
291
|
+
async ({ application_id, dry_run = true, allow_active_subscriptions = false, confirmation: approved }) => {
|
|
292
|
+
for (const [digest, preview] of previews) if (Date.now() >= preview.expires) previews.delete(digest);
|
|
293
|
+
let cached;
|
|
294
|
+
if (!dry_run) {
|
|
295
|
+
const match = /^DELETE CONSUMER APPLICATION ([a-f0-9]{64})$/.exec(approved ?? '');
|
|
296
|
+
cached = match ? previews.get(match[1]) : null;
|
|
297
|
+
if (!cached || cached.application_id !== application_id || cached.allow_active_subscriptions !== allow_active_subscriptions) {
|
|
298
|
+
throw new IntegrationError('A matching unexpired deletion preview confirmation is required; request a new dry run before any network access');
|
|
299
|
+
}
|
|
300
|
+
requireConfirmation(approved, cached.required_confirmation);
|
|
301
|
+
// Claim once before yielding to network I/O; concurrent executions cannot reuse this approval.
|
|
302
|
+
previews.delete(match[1]);
|
|
303
|
+
}
|
|
304
|
+
const inventory = await inspect(application_id);
|
|
305
|
+
if (!inventory) return { application_id, dry_run, accepted: false, already_absent: true, deleted: false, deletion_verified: true, ...credentialEffects };
|
|
306
|
+
const binding = { origin: origin(), application_id, allow_active_subscriptions, ...inventory.evidence };
|
|
307
|
+
const digest = canonicalDigest(binding);
|
|
308
|
+
const required = `DELETE CONSUMER APPLICATION ${digest}`;
|
|
309
|
+
const active = inventory.subscriptions.rows.filter(row => subscribed(row.isSubscribed) === true).length;
|
|
310
|
+
const dependencyBlocked = !allow_active_subscriptions && (active > 0 || inventory.legacy.rows.length > 0);
|
|
311
|
+
const blocked = !inventory.complete || dependencyBlocked;
|
|
312
|
+
if (dry_run) {
|
|
313
|
+
if (!blocked) {
|
|
314
|
+
if (previews.size >= 128 && !previews.has(digest)) previews.delete(previews.keys().next().value);
|
|
315
|
+
previews.set(digest, { application_id, allow_active_subscriptions, binding, required_confirmation: required, expires: Date.now() + PREVIEW_TTL });
|
|
316
|
+
}
|
|
317
|
+
return redactPrivateValues({
|
|
318
|
+
application_id, host: new URL(origin()).host, dry_run: true, complete: inventory.complete, blocked, allow_active_subscriptions,
|
|
319
|
+
owner: { identity_sha256: canonicalDigest(inventory.owner) },
|
|
320
|
+
subscriptions: { count: inventory.subscriptions.rows.length, active_count: active, complete: inventory.subscriptions.complete,
|
|
321
|
+
items: inventory.subscriptions.rows.slice(0, 100).map(row => subscriptionSummary(row, application_id)), summaries_truncated: inventory.subscriptions.rows.length > 100 },
|
|
322
|
+
legacy_product_links: { count: inventory.legacy.rows.length, complete: inventory.legacy.complete,
|
|
323
|
+
product_ids: inventory.legacy.rows.slice(0, 100).map(row => identityFor(row, 'ToAPIProductsDetails')), summaries_truncated: inventory.legacy.rows.length > 100 },
|
|
324
|
+
findings: [...inventory.findings, ...(dependencyBlocked ? ['active_subscriptions_or_legacy_product_links_require_explicit_permission'] : [])],
|
|
325
|
+
...(blocked ? {} : { required_confirmation: required, approval_expires_at: new Date(Date.now() + PREVIEW_TTL).toISOString() }),
|
|
326
|
+
impact: 'Delete only this consumer application and its application associations. No product/proxy/developer deletion; credential and token revocation are not verified.', ...credentialEffects
|
|
327
|
+
}, inventory.private_values);
|
|
328
|
+
}
|
|
329
|
+
if (!inventory.complete || blocked || Date.now() >= cached.expires || canonicalDigest(cached.binding) !== digest) throw new IntegrationError('Consumer owner or relationships changed, became incomplete, or approval expired; deletion refused. Request a new dry run.');
|
|
330
|
+
const base = { application_id, ...credentialEffects };
|
|
331
|
+
const clean = value => redactPrivateValues(value, inventory.private_values);
|
|
332
|
+
let response;
|
|
333
|
+
try { response = await request('DELETE', applicationPath(application_id), { expect: [...Array.from({ length: 100 }, (_, index) => 200 + index), 404], responseType: 'none', withStatus: true }); }
|
|
334
|
+
catch (error) { return clean({ ...base, accepted: Number.isInteger(error?.status) ? false : null, deleted: false, deletion_verified: false, outcome: 'unverified', source_result: failure(error) }); }
|
|
335
|
+
const accepted = response.status >= 200 && response.status < 300;
|
|
336
|
+
const result = { ...base, accepted, http_status: response.status, deleted: false, deletion_verified: false, outcome: 'unverified' };
|
|
337
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
338
|
+
if (attempt) await sleep(readBackDelayMs);
|
|
339
|
+
try {
|
|
340
|
+
if (!await namedApplication(application_id, false)) return clean({ ...result, deleted: accepted, already_absent: !accepted, deletion_verified: true, outcome: 'succeeded' });
|
|
341
|
+
} catch (error) { return clean({ ...result, source_result: failure(error) }); }
|
|
342
|
+
}
|
|
343
|
+
return clean({ ...result, pending: true });
|
|
344
|
+
});
|
|
345
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CREATE_ANNOTATIONS, OVERWRITE_ANNOTATIONS, READ_ONLY_ANNOTATIONS, entity, key, list, pick, redactSecrets, requireConfirmation, top } from './integration-common.mjs';
|
|
3
|
+
import { IntegrationError } from './integration-http.mjs';
|
|
4
|
+
import { redactPrivateValues } from './apim-runtime.mjs';
|
|
5
|
+
import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
|
|
6
|
+
import { registerDevhubRetirementTools } from './devhub-retirement.mjs';
|
|
7
|
+
|
|
8
|
+
const DATA = '/odata/1.0/data.svc';
|
|
9
|
+
const applicationId = z.string().min(1).max(256).regex(/^[A-Za-z0-9_-]+$/);
|
|
10
|
+
const developerId = z.string().min(1).max(256).regex(/^[A-Za-z0-9_.@-]+$/);
|
|
11
|
+
const productName = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
|
|
12
|
+
const confirmation = z.string().min(1);
|
|
13
|
+
const EMPTY_ID = '00000000000000000000000000000000';
|
|
14
|
+
|
|
15
|
+
function applicationSummary(value) {
|
|
16
|
+
if (!value) return null;
|
|
17
|
+
const cleaned = entity(value);
|
|
18
|
+
const summary = {
|
|
19
|
+
...redactSecrets(pick(cleaned, ['id', 'title', 'description', 'version', 'callbackurl', 'developer_id', 'reg_id', 'status', 'status_code', 'valid_from', 'valid_to', 'ToSubscriptions', 'ToAPIProductsDetails'])),
|
|
20
|
+
has_consumer_key: typeof cleaned.app_key === 'string' && cleaned.app_key.length > 0
|
|
21
|
+
};
|
|
22
|
+
return redactPrivateValues(summary, [cleaned.app_key, cleaned.app_secret]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function applicationListSummary(value) {
|
|
26
|
+
const application = entity(value);
|
|
27
|
+
return redactPrivateValues(pick(application, ['id', 'title', 'version', 'status', 'status_code', 'valid_from', 'valid_to']),
|
|
28
|
+
[application.app_key, application.app_secret]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function developerListSummary(value) {
|
|
32
|
+
return {
|
|
33
|
+
...pick(value, ['id', 'status']),
|
|
34
|
+
roles: list(value.rolesAccess).map(role => pick(role, ['role', 'status']))
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Register Developer Hub tools; application credentials are never returned. */
|
|
39
|
+
export function registerDevhubTools(register, client, { readBackDelayMs } = {}) {
|
|
40
|
+
const request = (method, path, options) => client.request(method, `${DATA}${path}`, options);
|
|
41
|
+
const collections = createCollectionPager(client, { root: DATA, request });
|
|
42
|
+
const registrations = createCollectionPager(client, { root: '/api/1.0' });
|
|
43
|
+
registerDevhubRetirementTools(register, client, { readBackDelayMs });
|
|
44
|
+
async function getApplication(id) {
|
|
45
|
+
const { status, data } = await request('GET', `/APIMgmt.Applications(${key(id)})`, {
|
|
46
|
+
query: { $expand: 'ToAPIProductsDetails,ToSubscriptions/ToAPIProduct,ToSubscriptions/ToRatePlan' },
|
|
47
|
+
expect: [200, 404], withStatus: true
|
|
48
|
+
});
|
|
49
|
+
if (status === 404) throw new IntegrationError(`Developer Hub application ${id} does not exist`);
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
register('devhub_list_developers', 'List Developer Hub developers',
|
|
54
|
+
'List registered Developer Hub role/status metadata without personal details or credentials. Requires a Developer Hub API-access service key.',
|
|
55
|
+
{ top: top(), cursor: cursorSchema }, READ_ONLY_ANNOTATIONS,
|
|
56
|
+
async args => registrations.page({
|
|
57
|
+
...args, path: '/registrations', query: { type: 'registered' }, mode: 'local', project: developerListSummary
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
register('devhub_create_developer', 'Onboard application developer',
|
|
61
|
+
'Submit Developer Hub onboarding for an application developer using the admin API-access key. Approval is a separate devhub_approve_developer operation. Requires confirmation CREATE DEVELOPER <user_id>.',
|
|
62
|
+
{
|
|
63
|
+
user_id: developerId,
|
|
64
|
+
first_name: z.string().min(1).max(255),
|
|
65
|
+
last_name: z.string().min(1).max(255),
|
|
66
|
+
email: z.string().email().max(255),
|
|
67
|
+
country: z.string().max(255).default(''),
|
|
68
|
+
reason: z.string().min(1).max(2048),
|
|
69
|
+
confirmation
|
|
70
|
+
}, CREATE_ANNOTATIONS,
|
|
71
|
+
async ({ user_id, first_name, last_name, email, country, reason, confirmation }) => {
|
|
72
|
+
requireConfirmation(confirmation, `CREATE DEVELOPER ${user_id}`);
|
|
73
|
+
await client.request('POST', '/api/1.0/registrations', {
|
|
74
|
+
json: {
|
|
75
|
+
userId: user_id, firstName: first_name, lastName: last_name, emailId: email, country,
|
|
76
|
+
rolesAccess: [{ requestReason: reason, role: 'API_ApplicationDeveloper' }]
|
|
77
|
+
},
|
|
78
|
+
expect: [201]
|
|
79
|
+
});
|
|
80
|
+
return { created: true, user_id, role: 'API_ApplicationDeveloper', approval_required: true };
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
register('devhub_approve_developer', 'Approve application developer',
|
|
84
|
+
'Approve an onboarded Developer Hub application developer using the admin API-access key. Requires confirmation APPROVE DEVELOPER <user_id>.',
|
|
85
|
+
{ user_id: developerId, reason: z.string().min(1).max(2048), confirmation }, OVERWRITE_ANNOTATIONS,
|
|
86
|
+
async ({ user_id, reason, confirmation }) => {
|
|
87
|
+
requireConfirmation(confirmation, `APPROVE DEVELOPER ${user_id}`);
|
|
88
|
+
await client.request('PUT', `/api/1.0/registrations/${encodeURIComponent(user_id)}`, {
|
|
89
|
+
json: { rolesAccess: [{ status: 'registered', role: 'API_ApplicationDeveloper', responseReason: reason }] },
|
|
90
|
+
expect: [200, 204]
|
|
91
|
+
});
|
|
92
|
+
return { approved: true, user_id, role: 'API_ApplicationDeveloper' };
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
register('devhub_create_application', 'Create subscribed consumer application',
|
|
96
|
+
'Create a Developer Hub application on behalf of a registered developer with product subscriptions. SAP provisions the consumer key; key/secret values are redacted from output. External governance, if configured, may leave subscriptions pending. Requires confirmation CREATE CONSUMER APPLICATION <application_id>.',
|
|
97
|
+
{
|
|
98
|
+
application_id: applicationId,
|
|
99
|
+
developer_id: developerId,
|
|
100
|
+
title: z.string().min(1).max(255),
|
|
101
|
+
description: z.string().max(2048).default(''),
|
|
102
|
+
callback_url: z.string().url().max(2048).refine(value => {
|
|
103
|
+
const url = new URL(value);
|
|
104
|
+
return url.protocol === 'https:' && !url.username && !url.password;
|
|
105
|
+
}, 'callback URL must use HTTPS without embedded credentials').optional(),
|
|
106
|
+
products: z.array(productName).min(1).max(50).refine(values => new Set(values).size === values.length, 'products must be unique'),
|
|
107
|
+
confirmation
|
|
108
|
+
}, CREATE_ANNOTATIONS,
|
|
109
|
+
async ({ application_id, developer_id, title, description, callback_url, products, confirmation }) => {
|
|
110
|
+
requireConfirmation(confirmation, `CREATE CONSUMER APPLICATION ${application_id}`);
|
|
111
|
+
const found = list(await request('GET', '/APIMgmt.Applications', {
|
|
112
|
+
query: { $filter: `id eq '${application_id}'`, $top: 1 }
|
|
113
|
+
}));
|
|
114
|
+
if (found.some(app => app.id === application_id)) throw new IntegrationError(`Developer Hub application ${application_id} already exists; read it instead of creating it again`);
|
|
115
|
+
for (const product of products) {
|
|
116
|
+
const { status } = await request('GET', `/APIMgmt.APIProducts(${key(product)})`, { expect: [200, 404], withStatus: true });
|
|
117
|
+
if (status === 404) throw new IntegrationError(`Developer Hub product ${product} is not available; publish it before creating the application`);
|
|
118
|
+
}
|
|
119
|
+
const created = await request('POST', '/APIMgmt.Applications', {
|
|
120
|
+
json: {
|
|
121
|
+
id: application_id, version: '1', title, description, callbackurl: callback_url ?? null, developer_id,
|
|
122
|
+
ToSubscriptions: products.map(product => ({
|
|
123
|
+
id: EMPTY_ID, ToAPIProduct: [{ __metadata: { uri: `APIMgmt.APIProducts('${product}')` } }]
|
|
124
|
+
}))
|
|
125
|
+
},
|
|
126
|
+
expect: [200, 201]
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
created: true, application_id, application: applicationSummary(created),
|
|
130
|
+
note: 'Read the application to check subscription approval and key availability. Do not repeat creation after a successful write.'
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
register('devhub_subscribe_application', 'Subscribe application to a product',
|
|
135
|
+
'Create a product subscription for an existing subscription-based Developer Hub application. Does not bypass external governance or return key/secret values. Requires confirmation SUBSCRIBE APPLICATION <application_id> TO <product>.',
|
|
136
|
+
{ application_id: applicationId, product: productName, confirmation }, CREATE_ANNOTATIONS,
|
|
137
|
+
async ({ application_id, product, confirmation }) => {
|
|
138
|
+
requireConfirmation(confirmation, `SUBSCRIBE APPLICATION ${application_id} TO ${product}`);
|
|
139
|
+
const application = await getApplication(application_id);
|
|
140
|
+
const subscriptions = list(application.ToSubscriptions);
|
|
141
|
+
if (subscriptions.some(subscription => subscription.product_id === product)) {
|
|
142
|
+
throw new IntegrationError(`Application ${application_id} already has a subscription for ${product}; read its status instead of creating a duplicate`);
|
|
143
|
+
}
|
|
144
|
+
if (!subscriptions.length && list(application.ToAPIProductsDetails).length) {
|
|
145
|
+
throw new IntegrationError('This application uses legacy product links; create a subscription-based application instead of mixing subscription models');
|
|
146
|
+
}
|
|
147
|
+
const created = await request('POST', '/APIMgmt.Subscriptions', {
|
|
148
|
+
json: {
|
|
149
|
+
id: EMPTY_ID,
|
|
150
|
+
ToAPIProduct: [{ __metadata: { uri: `APIMgmt.APIProducts('${product}')` } }],
|
|
151
|
+
ToApplication: [{ __metadata: { uri: `APIMgmt.Applications('${application_id}')` } }]
|
|
152
|
+
},
|
|
153
|
+
expect: [200, 201]
|
|
154
|
+
});
|
|
155
|
+
return { created: true, application_id, product, subscription: redactPrivateValues(redactSecrets(entity(created)), [application.app_key, application.app_secret]) };
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
register('devhub_list_applications', 'List consumer applications',
|
|
159
|
+
'List Developer Hub application metadata. Consumer keys and secrets are not returned; use devhub_get_application for subscription details.',
|
|
160
|
+
{ top: top(), cursor: cursorSchema }, READ_ONLY_ANNOTATIONS,
|
|
161
|
+
async args => collections.page({
|
|
162
|
+
...args, path: '/APIMgmt.Applications', orderBy: 'id asc', identity: application => application.id,
|
|
163
|
+
project: applicationListSummary
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
register('devhub_get_application', 'Get consumer application',
|
|
167
|
+
'Read one Developer Hub application with its product subscriptions. Consumer key/secret values are redacted; has_consumer_key reports key availability.',
|
|
168
|
+
{ application_id: applicationId }, READ_ONLY_ANNOTATIONS,
|
|
169
|
+
async ({ application_id }) => applicationSummary(await getApplication(application_id)));
|
|
170
|
+
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function devhubToolNames() {
|
|
174
|
+
const names = [];
|
|
175
|
+
registerDevhubTools(name => names.push(name), null);
|
|
176
|
+
return names;
|
|
177
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Read-only Edge local API discovery and shared, payload-free MPL diagnosis.
|
|
2
|
+
import { READ_ONLY_ANNOTATIONS } from './integration-common.mjs';
|
|
3
|
+
import { IntegrationError } from './integration-http.mjs';
|
|
4
|
+
import { createCollectionPager } from './integration-collections.mjs';
|
|
5
|
+
import { getMessageLog, listMessageLogs, messageGuid, mplListInputs } from './cpi-monitoring.mjs';
|
|
6
|
+
import { childElements, xmlChildren, xmlAttributes } from './apim-bundle.mjs';
|
|
7
|
+
|
|
8
|
+
const ROOT = '/local/api/eic/v1';
|
|
9
|
+
const MPL_ROOT = '/local/api/v1';
|
|
10
|
+
const SOURCES = [
|
|
11
|
+
'https://github.com/SAP-docs/btp-integration-suite/blob/main/docs/ISuite_Edge_Integration_Cell/local-odata-api-access-fe61332.md',
|
|
12
|
+
'https://github.com/SAP-docs/btp-integration-suite/blob/main/docs/ISuite_Edge_Integration_Cell/operations-cockpit-api-da1abc0.md'
|
|
13
|
+
];
|
|
14
|
+
const ROUTES = [
|
|
15
|
+
'/Components', '/Components/<component ID>/Pods', '/Components/<component ID>/Pods/<pod name>/Events',
|
|
16
|
+
'/Components/<component ID>/Pods/<pod name>/Metrics', '/Components/<component ID>/RuntimeParameters',
|
|
17
|
+
'/Jobs', '/Jobs/<jobId>/Schedule'
|
|
18
|
+
];
|
|
19
|
+
const localName = element => element.name.split(':').at(-1);
|
|
20
|
+
const identifier = value => typeof value === 'string' && value.length <= 200 && /^[A-Za-z_][A-Za-z0-9_.]*$/.test(value);
|
|
21
|
+
|
|
22
|
+
function operationalBoundary() {
|
|
23
|
+
return {
|
|
24
|
+
published_routes: ROUTES.map(path => `${ROOT}${path}`),
|
|
25
|
+
operational_field_contract: 'not_established',
|
|
26
|
+
discovery_scope: 'Entity presence is discovery only, not an administrative or field-level operational contract.',
|
|
27
|
+
native_handoff: {
|
|
28
|
+
prerequisites: ['Exact Edge cell/runtime, component/pod/job identities and authorized native cockpit access'],
|
|
29
|
+
steps: ['Open the Edge Integration Cell Operations Cockpit', 'Select Component Monitor or Job Management and the exact component/pod/job', 'Inspect native status, event logs, metrics or schedule; correlate supported local MPL identities separately'],
|
|
30
|
+
safety_warning: 'No component status guesses, restart, Kubernetes, job-control, message-store or payload access is provided.',
|
|
31
|
+
post_action_verification: ['Refresh native diagnostics and read bounded local MPL metadata for the exact message/time window']
|
|
32
|
+
},
|
|
33
|
+
source_links: [...SOURCES]
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function metadataInventory(xml) {
|
|
38
|
+
if (typeof xml !== 'string' || /<!DOCTYPE|<!ENTITY/i.test(xml)) throw new IntegrationError('Edge local metadata is not a supported OData metadata document');
|
|
39
|
+
try {
|
|
40
|
+
const roots = childElements(xml, 0, xml.length);
|
|
41
|
+
if (roots.length !== 1 || localName(roots[0]) !== 'Edmx') throw new Error('root');
|
|
42
|
+
const services = xmlChildren(xml, roots[0]).filter(element => localName(element) === 'DataServices');
|
|
43
|
+
if (services.length !== 1) throw new Error('services');
|
|
44
|
+
const schemas = [];
|
|
45
|
+
const entity_sets = [];
|
|
46
|
+
let complete = true;
|
|
47
|
+
const accept = (array, value) => {
|
|
48
|
+
array.push(value);
|
|
49
|
+
if (Buffer.byteLength(JSON.stringify({ schemas, entity_sets }, null, 2)) > 80 * 1024) { array.pop(); complete = false; }
|
|
50
|
+
};
|
|
51
|
+
for (const schema of xmlChildren(xml, services[0]).filter(element => localName(element) === 'Schema')) {
|
|
52
|
+
const namespace = xmlAttributes(xml, schema).Namespace;
|
|
53
|
+
if (!identifier(namespace)) { complete = false; continue; }
|
|
54
|
+
accept(schemas, namespace);
|
|
55
|
+
for (const container of xmlChildren(xml, schema).filter(element => localName(element) === 'EntityContainer')) {
|
|
56
|
+
for (const set of xmlChildren(xml, container).filter(element => localName(element) === 'EntitySet')) {
|
|
57
|
+
const attrs = xmlAttributes(xml, set);
|
|
58
|
+
if (!identifier(attrs.Name) || !identifier(attrs.EntityType)) { complete = false; continue; }
|
|
59
|
+
accept(entity_sets, { name: attrs.Name, entity_type: attrs.EntityType });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!schemas.length) throw new Error('schemas');
|
|
64
|
+
return { schemas, entity_sets, complete, stop_reason: complete ? 'end' : 'source_limit' };
|
|
65
|
+
} catch {
|
|
66
|
+
// Never copy an unexpected XML tag, annotation, property or schema body into diagnostics.
|
|
67
|
+
throw new IntegrationError('Edge local metadata is not a supported OData metadata document');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function localRead(operation) {
|
|
72
|
+
try { return await operation(); }
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!Number.isInteger(error?.status) || error.status < 400) throw error;
|
|
75
|
+
const detail = error.status === 401 ? 'A local 401 may indicate a missing /local prefix or authentication failure.'
|
|
76
|
+
: error.status === 403 ? 'Access is denied; an unsupported local endpoint can also return 403.'
|
|
77
|
+
: 'The requested local read could not be completed.';
|
|
78
|
+
throw new IntegrationError(`Edge local API read failed (HTTP ${error.status}). ${detail} Activation and entitlement remain unknown; no health or no-traffic conclusion is established.`, { status: error.status });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Construction performs no network or client access, including null-client name discovery. */
|
|
83
|
+
export function registerEdgeMonitoringTools(register, client) {
|
|
84
|
+
const read = (path, options) => client.request('GET', `${MPL_ROOT}${path}`, options);
|
|
85
|
+
const pager = createCollectionPager(client, { root: MPL_ROOT, request: (_method, path, options) => read(path, options) });
|
|
86
|
+
|
|
87
|
+
register('edge_get_api_metadata', 'Discover Edge local API metadata',
|
|
88
|
+
'Read /local/api/eic/v1/$metadata as bounded schema/entity-set identities only. Published component/pod/job routes are discovery, not safe field-level status contracts. Use the native Operations Cockpit for those diagnostics; no raw XML, source, payloads, actions or arbitrary reads.',
|
|
89
|
+
{}, READ_ONLY_ANNOTATIONS, () => localRead(async () => {
|
|
90
|
+
const xml = await client.request('GET', `${ROOT}/$metadata`, { responseType: 'text', accept: 'application/xml, text/xml' });
|
|
91
|
+
return { result: 'ok', service_root: ROOT, ...metadataInventory(xml), ...operationalBoundary() };
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
register('edge_list_message_logs', 'List Edge local message logs',
|
|
95
|
+
'Read bounded local MPL metadata in the shared fixed UTC [from,to) LogStart window of at most 31 days. Supply both timestamps or neither (frozen last 24 hours). Signed continuations retain that window; traversal is best effort. /local and configured local Basic authority are preserved. Access denial establishes neither no traffic nor non-entitlement.',
|
|
96
|
+
mplListInputs, READ_ONLY_ANNOTATIONS, args => localRead(() => listMessageLogs(pager, args)));
|
|
97
|
+
|
|
98
|
+
register('edge_get_message_log', 'Get Edge local message log',
|
|
99
|
+
'Read one local MPL identity, bounded redacted error text and credential-filtered custom header properties using the same diagnostic contract as CPI. No payloads, attachments, cloud statistics or JMS access. A local 401 may indicate missing /local; unsupported endpoints can return 403, not proof of non-entitlement.',
|
|
100
|
+
{ message_guid: messageGuid }, READ_ONLY_ANNOTATIONS, ({ message_guid }) => localRead(() => getMessageLog(read, pager, message_guid)));
|
|
101
|
+
}
|