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.
Files changed (59) hide show
  1. package/.github/agents/sap-integration-architect.agent.md +32 -18
  2. package/.github/agents/sap-integration-developer.agent.md +28 -12
  3. package/.github/skills/integration-suite-delivery/SKILL.md +94 -37
  4. package/.github/skills/sap-integration-architect/SKILL.md +49 -25
  5. package/README.md +72 -15
  6. package/dist/checksums.txt +5 -5
  7. package/dist/vsp-darwin-arm64 +0 -0
  8. package/dist/vsp-darwin-x64 +0 -0
  9. package/dist/vsp-linux-arm64 +0 -0
  10. package/dist/vsp-linux-x64 +0 -0
  11. package/dist/vsp-win32-x64.exe +0 -0
  12. package/inventory.md +97 -57
  13. package/package.json +1 -1
  14. package/scripts/check-live-integration.mjs +151 -0
  15. package/scripts/install-local.mjs +11 -4
  16. package/scripts/run-live-integration.mjs +55 -63
  17. package/src/apim-bundle.mjs +416 -13
  18. package/src/apim-cleanup-tools.mjs +145 -0
  19. package/src/apim-content.mjs +157 -0
  20. package/src/apim-delete-tools.mjs +139 -0
  21. package/src/apim-documentation.mjs +162 -0
  22. package/src/apim-inspection.mjs +141 -0
  23. package/src/apim-monitoring.mjs +140 -0
  24. package/src/apim-policy-steps.mjs +31 -0
  25. package/src/apim-runtime.mjs +262 -0
  26. package/src/apim-tools.mjs +202 -109
  27. package/src/cf-integration.mjs +46 -7
  28. package/src/cpi-async-monitoring.mjs +177 -0
  29. package/src/cpi-content.mjs +92 -0
  30. package/src/cpi-monitoring.mjs +225 -0
  31. package/src/cpi-operational.mjs +129 -0
  32. package/src/cpi-release.mjs +273 -0
  33. package/src/cpi-security.mjs +48 -0
  34. package/src/cpi-tools.mjs +184 -319
  35. package/src/devhub-retirement.mjs +345 -0
  36. package/src/devhub-tools.mjs +177 -0
  37. package/src/edge-monitoring.mjs +101 -0
  38. package/src/iflow-bundle.mjs +295 -7
  39. package/src/integration-alerts.mjs +248 -0
  40. package/src/integration-analysis.mjs +488 -0
  41. package/src/integration-capabilities.mjs +181 -0
  42. package/src/integration-collections.mjs +203 -0
  43. package/src/integration-common.mjs +140 -35
  44. package/src/integration-config.mjs +20 -14
  45. package/src/integration-credentials.mjs +16 -10
  46. package/src/integration-http.mjs +255 -67
  47. package/src/integration-messaging.mjs +118 -0
  48. package/src/integration-optional-config.mjs +122 -0
  49. package/src/integration-optional-tools.mjs +153 -0
  50. package/src/integration-release-store.mjs +410 -0
  51. package/src/integration-server.mjs +34 -16
  52. package/src/integration-state.mjs +31 -0
  53. package/src/integration-tools.mjs +44 -8
  54. package/src/integration-transport.mjs +184 -0
  55. package/src/integration-validation.mjs +26 -0
  56. package/src/integration-verify.mjs +143 -62
  57. package/src/open-connectors-tools.mjs +143 -0
  58. package/src/setup.mjs +86 -52
  59. package/tools.md +193 -26
@@ -0,0 +1,273 @@
1
+ // CPI release operations keep acceptance separate from completed business/content/runtime evidence.
2
+ import { z } from 'zod';
3
+ import { CREATE_ANNOTATIONS, READ_ONLY_ANNOTATIONS, confirmation, key, requireConfirmation, sleep } from './integration-common.mjs';
4
+ import { IntegrationError, odataDate, odataKey } from './integration-http.mjs';
5
+ import { CPI_BASE, cpiArtifactPath, readCpiContent, cpiReleaseFailure } from './cpi-content.mjs';
6
+ import { rememberValidation } from './integration-validation.mjs';
7
+ import { bundleDigest, MANIFEST_PATH, replaceManifestVersion, strFromU8, strToU8 } from './iflow-bundle.mjs';
8
+ import { stateDigest } from './integration-state.mjs';
9
+
10
+ const idSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9_.-]{0,99}$/);
11
+ const namedVersion = z.string().regex(/^\d+\.\d+\.\d+$/);
12
+ export const cpiVersionSchema = z.string().regex(/^(?:active|\d+\.\d+\.\d+)$/).default('active');
13
+ const waitSchema = z.number().int().min(0).max(50).default(45);
14
+ const taskSchema = z.string().regex(/^[A-Za-z0-9_.:-]{1,200}$/);
15
+ const tasks = new WeakMap();
16
+ const artifactTypes = {
17
+ value_mapping: { set: 'ValueMappingDesigntimeArtifacts', deploy: 'DeployValueMappingDesigntimeArtifact' },
18
+ script_collection: { set: 'ScriptCollectionDesigntimeArtifacts', deploy: 'DeployScriptCollectionDesigntimeArtifact' },
19
+ message_mapping: { set: 'MessageMappingDesigntimeArtifacts', deploy: 'DeployMessageMappingDesigntimeArtifact' }
20
+ };
21
+ export const CPI_ARTIFACT_TYPES = Object.freeze(artifactTypes);
22
+ function privateError(error) {
23
+ const evidence = cpiReleaseFailure(error);
24
+ return new IntegrationError(`CPI release source request failed${evidence.status ? ` (HTTP ${evidence.status})` : ''}; tenant text withheld`, {
25
+ ...evidence, ...(error?.code === 'INTEGRATION_DEADLINE' ? { code: error.code } : {})
26
+ });
27
+ }
28
+ async function request(client, method, path, options) {
29
+ try { return await client.request(method, `${CPI_BASE}${path}`, options); }
30
+ catch (error) { throw privateError(error); }
31
+ }
32
+ async function releaseContent(client, id, version, options) {
33
+ try {
34
+ const content = await readCpiContent(client, id, version, options);
35
+ if (version !== 'active' && content.resolved_version !== version) throw new IntegrationError('CPI selected retained version identity mismatch');
36
+ return content;
37
+ }
38
+ catch (error) { throw privateError(error); }
39
+ }
40
+ const safeStatus = value => typeof value === 'string' && /^[A-Za-z0-9_ -]{1,100}$/.test(value) ? value : null;
41
+ const runtimePath = id => `/IntegrationRuntimeArtifacts(${key(id)})`;
42
+
43
+ export function parseCpiValidation(text) {
44
+ const match = /^\s*Checkexecutionresult:\s*(Passed|Failed)\b/i.exec(typeof text === 'string' ? text : '');
45
+ const passed = match ? match[1].toLowerCase() === 'passed' : null;
46
+ const diagnostics = [];
47
+ let diagnostics_truncated = false;
48
+ if (match && passed === false) {
49
+ try {
50
+ const raw = JSON.parse(text.slice(match[0].length).trim());
51
+ if (Array.isArray(raw)) {
52
+ diagnostics_truncated = raw.length > 50;
53
+ for (const item of raw.slice(0, 50)) {
54
+ const identity = (value, pattern, max) => typeof value === 'string' && value.length <= max && pattern.test(value) ? value : null;
55
+ diagnostics.push({ severity: identity(item?.severity, /^(?:Error|Warning|Info|Information)$/i, 20),
56
+ // Arbitrary tenant messages can echo source/configuration. Structural identities remain useful without that unsafe text.
57
+ message: 'CPI validation diagnostic; free-form message withheld.', message_withheld: true,
58
+ sourceObject: identity(item?.sourceObject, /^[A-Za-z0-9_./: -]+$/, 500),
59
+ resourcePath: identity(item?.resourcePath, /^src\/[A-Za-z0-9_./ -]+$/, 300),
60
+ resourceName: identity(item?.resourceName, /^[A-Za-z0-9_. -]+$/, 200) });
61
+ }
62
+ } else diagnostics_truncated = true;
63
+ } catch { diagnostics_truncated = true; }
64
+ }
65
+ return { passed, diagnostics, diagnostics_truncated, response_recognized: passed !== null };
66
+ }
67
+
68
+ async function validate(client, { iflow_id, version, confirmation: confirmed }) {
69
+ requireConfirmation(confirmed, `VALIDATE IFLOW ${iflow_id} VERSION ${version}`);
70
+ const deadline = Date.now() + 55000;
71
+ const before = await releaseContent(client, iflow_id, version, { deadline });
72
+ let response;
73
+ try {
74
+ response = await request(client, 'POST', '/ValidateIntegrationDesigntimeArtifact', {
75
+ query: { Id: odataKey(iflow_id), Version: odataKey(version) }, responseType: 'text', accept: '*/*', expect: [200, 202], withStatus: true, deadline
76
+ });
77
+ } catch (error) {
78
+ rememberValidation(client, { id: iflow_id, ...before, passed: null, matching_state: false });
79
+ const status = Number(error?.status);
80
+ return { iflow_id, selector: version, resolved_version: before.resolved_version, passed: null,
81
+ validation_accepted: status >= 200 && status < 300 ? true : status >= 400 ? false : null,
82
+ validation: 'unverified', source_result: cpiReleaseFailure(error), scope: 'iflow_all_option_configuration' };
83
+ }
84
+ const result = parseCpiValidation(response.data);
85
+ let matching_state = false;
86
+ let source_result;
87
+ try {
88
+ const after = await readCpiContent(client, iflow_id, version, { deadline });
89
+ matching_state = after.resolved_version === before.resolved_version && after.state_sha256 === before.state_sha256;
90
+ } catch (error) { source_result = cpiReleaseFailure(error); }
91
+ rememberValidation(client, { id: iflow_id, ...before, passed: result.passed, matching_state });
92
+ return { iflow_id, selector: version, resolved_version: before.resolved_version, ...result,
93
+ validation_accepted: true, http_status: response.status, matching_state,
94
+ bundle_sha256: before.bundle_sha256, state_sha256: before.state_sha256,
95
+ ...(source_result ? { source_result } : {}), scope: 'iflow_all_option_configuration' };
96
+ }
97
+
98
+ function normalizedBundleDigest(content, version) {
99
+ const files = new Map(content.files);
100
+ if (!files.has(MANIFEST_PATH)) throw new IntegrationError('CPI bundle has no manifest for retained-version comparison');
101
+ files.set(MANIFEST_PATH, strToU8(replaceManifestVersion(strFromU8(files.get(MANIFEST_PATH)), version)));
102
+ return bundleDigest(files);
103
+ }
104
+
105
+ async function saveVersion(client, { iflow_id, version, expected_current_version, expected_bundle_sha256, confirmation: confirmed }) {
106
+ requireConfirmation(confirmed, `SAVE IFLOW VERSION ${iflow_id} ${version}`);
107
+ const deadline = Date.now() + 55000;
108
+ const current = await releaseContent(client, iflow_id, 'active', { deadline });
109
+ if (current.resolved_version !== expected_current_version || current.bundle_sha256 !== expected_bundle_sha256) {
110
+ throw new IntegrationError('CPI current draft version/content changed; re-read before saving a retained version');
111
+ }
112
+ const existing = await request(client, 'GET', cpiArtifactPath(iflow_id, version), { expect: [200, 404], withStatus: true, deadline });
113
+ if (existing.status !== 404) throw new IntegrationError('The requested retained iFlow version already exists');
114
+ // Do not upload or silently save a new draft: this operation saves the approved current draft exactly once.
115
+ let response;
116
+ try {
117
+ response = await request(client, 'POST', '/IntegrationDesigntimeArtifactSaveAsVersion', {
118
+ query: { Id: odataKey(iflow_id), SaveAsVersion: odataKey(version) }, responseType: 'none', accept: '*/*', expect: [200, 201, 202, 204], withStatus: true, deadline
119
+ });
120
+ } catch (error) {
121
+ const status = Number(error?.status);
122
+ return { iflow_id, version, saved: false, save_accepted: status >= 200 && status < 300 ? true : status >= 400 ? false : null,
123
+ retained_version_created: null, outcome: 'unverified', source_result: cpiReleaseFailure(error) };
124
+ }
125
+ const result = { iflow_id, version, previous_version: current.resolved_version, save_accepted: true,
126
+ http_status: response.status, retained_version_created: null, saved: false, outcome: 'unverified',
127
+ validation_required: true, note: 'Validate this exact saved version before release readiness.' };
128
+ try {
129
+ const saved = await readCpiContent(client, iflow_id, version, { deadline });
130
+ const matches = saved.resolved_version === version && normalizedBundleDigest(saved, version) === normalizedBundleDigest(current, version)
131
+ && stateDigest('', saved.configurations) === stateDigest('', current.configurations);
132
+ return { ...result, saved: matches, retained_version_created: saved.resolved_version === version ? true : null, outcome: matches ? 'succeeded' : 'unverified',
133
+ resolved_version: saved.resolved_version, bundle_sha256: saved.bundle_sha256, state_sha256: saved.state_sha256,
134
+ content_verified: matches };
135
+ } catch (error) { return { ...result, source_result: cpiReleaseFailure(error) }; }
136
+ }
137
+
138
+ async function readRuntime(client, id, deadline) {
139
+ const response = await request(client, 'GET', runtimePath(id), { expect: [200, 404], withStatus: true, deadline });
140
+ const value = response.status === 200 ? response.data : null;
141
+ return { present: response.status === 200, status: safeStatus(value?.Status),
142
+ version: typeof value?.Version === 'string' && /^[A-Za-z0-9_.-]{1,100}$/.test(value.Version) ? value.Version : null,
143
+ deployed_on: typeof value?.DeployedOn === 'string' && Number.isFinite(Date.parse(odataDate(value.DeployedOn))) ? odataDate(value.DeployedOn) : null };
144
+ }
145
+ function runtimeFields(runtime) {
146
+ return { runtime_status: runtime.status, runtime_version: runtime.version, deployed_on: runtime.deployed_on };
147
+ }
148
+ function freshRuntime(context, runtime) {
149
+ const previous = context.pre_deploy_runtime;
150
+ if (!previous || !runtime.present || runtime.status !== 'STARTED' || !context.requested_version || runtime.version !== context.requested_version) return false;
151
+ return !previous.present || runtime.version !== previous.version
152
+ || runtime.deployed_on !== null && previous.deployed_on !== null && Date.parse(runtime.deployed_on) > Date.parse(previous.deployed_on);
153
+ }
154
+ function outcome(context, build, runtime) {
155
+ if (['FAIL', 'FAIL_ON_LICENSE_ERROR'].includes(build)) return 'failed';
156
+ const fresh = freshRuntime(context, runtime);
157
+ const runtimeChanged = !context.pre_deploy_runtime?.present || runtime.version !== context.pre_deploy_runtime.version
158
+ || runtime.deployed_on !== null && runtime.deployed_on !== context.pre_deploy_runtime.deployed_on;
159
+ if (runtime.status === 'ERROR' && runtimeChanged) return 'failed';
160
+ if (build === 'DEPLOYING') return 'pending';
161
+ if (build === 'SUCCESS' && fresh) return 'succeeded';
162
+ // Shared-artifact imports may return no task ID. Fresh exact runtime identity is then the available authoritative evidence.
163
+ if (!context.task_required && !context.task_id && fresh) return 'succeeded';
164
+ if (build === 'SUCCESS' && (runtime.status === null || runtime.status === 'STARTING')) return 'pending';
165
+ if (!context.task_required && !context.task_id && (runtime.status === null || runtime.status === 'STARTING')) return 'pending';
166
+ return 'unverified';
167
+ }
168
+ async function runtimeErrorEvidence(client, context, result, deadline) {
169
+ if (result.runtime_status !== 'ERROR') return;
170
+ try {
171
+ const response = await request(client, 'GET', `${runtimePath(context.artifact_id)}/ErrorInformation/$value`, { responseType: 'text', accept: '*/*', expect: [200, 204], deadline });
172
+ result.error_information = response ? 'CPI runtime error information is available; free-form text withheld.' : null;
173
+ result.error_information_withheld = Boolean(response);
174
+ } catch (error) { result.error_source_result = cpiReleaseFailure(error); }
175
+ }
176
+ async function observe(client, context, deadline) {
177
+ const result = { ...context, outcome: 'unverified', build_status: null, runtime_status: null, runtime_version: null, deployed_on: null,
178
+ error_information: null, timed_out: false };
179
+ try {
180
+ if (context.task_id) {
181
+ const build = await request(client, 'GET', `/BuildAndDeployStatus(TaskId=${key(context.task_id)})`, { deadline });
182
+ result.build_status = safeStatus(build?.Status);
183
+ if (build?.TaskId !== undefined && build.TaskId !== context.task_id) {
184
+ result.source_result = { result: 'error', reason: 'task_identity_mismatch' };
185
+ return result;
186
+ }
187
+ }
188
+ const runtime = context.artifact_id ? await readRuntime(client, context.artifact_id, deadline) : { present: false, status: null, version: null, deployed_on: null };
189
+ Object.assign(result, runtimeFields(runtime));
190
+ result.outcome = outcome(context, result.build_status, runtime);
191
+ await runtimeErrorEvidence(client, context, result, deadline);
192
+ } catch (error) {
193
+ result.source_result = cpiReleaseFailure(error);
194
+ result.timed_out = error?.code === 'INTEGRATION_DEADLINE';
195
+ result.outcome = ['FAIL', 'FAIL_ON_LICENSE_ERROR'].includes(result.build_status) ? 'failed' : result.timed_out ? 'pending' : 'unverified';
196
+ }
197
+ return result;
198
+ }
199
+
200
+ export async function deployCpiArtifact(client, { artifact_id, type = 'iflow', version = 'active', wait_seconds = 45 }, { pollIntervalMs = 1000 } = {}) {
201
+ // Zero wait still initiates once, but performs no post-acceptance reads. Prerequisites/POST use a bounded request deadline.
202
+ const started = Date.now();
203
+ const deadline = started + (wait_seconds === 0 ? 50000 : wait_seconds * 1000);
204
+ const kind = type === 'iflow' ? { set: 'IntegrationDesigntimeArtifacts', deploy: 'DeployIntegrationDesigntimeArtifact' } : artifactTypes[type];
205
+ const design = await request(client, 'GET', `/${kind.set}(Id=${key(artifact_id)},Version=${key(version)})`, { deadline });
206
+ if (design?.Id !== artifact_id || typeof design?.Version !== 'string' || !/^\d+\.\d+\.\d+$/.test(design.Version)
207
+ || version !== 'active' && design.Version !== version) throw new IntegrationError('CPI requested design identity/version is unavailable or mismatched');
208
+ const pre_deploy_runtime = await readRuntime(client, artifact_id, deadline);
209
+ const context = { artifact_id, ...(type === 'iflow' ? { iflow_id: artifact_id } : { type }), requested_selector: version, requested_version: design.Version,
210
+ pre_deploy_runtime, task_id: null, task_required: type === 'iflow', deployment_accepted: true };
211
+ let response;
212
+ try {
213
+ response = await request(client, 'POST', `/${kind.deploy}`, { query: { Id: odataKey(artifact_id), Version: odataKey(version) },
214
+ responseType: 'text', accept: '*/*', expect: [200, 201, 202], withStatus: true, deadline });
215
+ } catch (error) {
216
+ const status = Number(error?.status);
217
+ return { ...context, deployment_accepted: status >= 200 && status < 300 ? true : status >= 400 ? false : null,
218
+ outcome: 'unverified', build_status: null, ...runtimeFields(pre_deploy_runtime), source_result: cpiReleaseFailure(error), timed_out: error?.code === 'INTEGRATION_DEADLINE' };
219
+ }
220
+ context.acceptance_status = response.status;
221
+ const body = typeof response.data === 'string' ? response.data.trim().replace(/^"|"$/g, '') : '';
222
+ context.task_id = taskSchema.safeParse(body).success ? body : null;
223
+ context.response_body_present = body.length > 0;
224
+ if (context.task_id) {
225
+ let entries = tasks.get(client);
226
+ if (!entries) { entries = new Map(); tasks.set(client, entries); }
227
+ // Keep bounded private continuation baselines, not an unbounded deployment history.
228
+ if (entries.size >= 200) entries.delete(entries.keys().next().value);
229
+ entries.set(context.task_id, context);
230
+ }
231
+ let result = { ...context, outcome: 'pending', build_status: null, runtime_status: null, runtime_version: null, deployed_on: null, error_information: null, timed_out: false };
232
+ if (wait_seconds === 0) return result;
233
+ if (context.task_required && !context.task_id) return { ...result, outcome: 'unverified', note: 'Deployment was accepted without a usable task ID; do not redeploy to recover observation.' };
234
+ for (;;) {
235
+ if (Date.now() >= deadline) return { ...result, timed_out: true };
236
+ result = await observe(client, context, deadline);
237
+ if (['succeeded', 'failed'].includes(result.outcome) || result.source_result || result.build_status !== null && !['DEPLOYING', 'SUCCESS'].includes(result.build_status)) return result;
238
+ if (context.task_id && result.build_status === null) return result;
239
+ if (Date.now() >= deadline) return { ...result, timed_out: true };
240
+ // A completed same-version deployment with unchanged timestamp has no provable fresh identity.
241
+ if (result.outcome === 'unverified' && result.runtime_status === 'STARTED' && result.runtime_version === context.requested_version && context.pre_deploy_runtime.version === context.requested_version) return result;
242
+ await sleep(Math.min(Math.max(1, pollIntervalMs), Math.max(1, deadline - Date.now())));
243
+ }
244
+ }
245
+
246
+ export function registerCpiReleaseTools(register, client, { pollIntervalMs } = {}) {
247
+ register('cpi_validate_iflow', 'Validate iFlow configuration',
248
+ 'Validate one selected iFlow and its All-option configuration through the documented bodyless POST. HTTP acceptance is distinct from Passed/Failed. Exact confirmation: VALIDATE IFLOW <iflow_id> VERSION <version>.',
249
+ { iflow_id: idSchema, version: cpiVersionSchema, confirmation }, CREATE_ANNOTATIONS, args => validate(client, args));
250
+ register('cpi_save_iflow_version', 'Save retained iFlow version',
251
+ 'Save the approved current draft as a retained version, without uploading or deploying. Fresh current-version and bundle-digest preconditions are required. Validate the exact saved version separately. Exact confirmation: SAVE IFLOW VERSION <iflow_id> <version>.',
252
+ { iflow_id: idSchema, version: namedVersion, expected_current_version: namedVersion, expected_bundle_sha256: z.string().regex(/^[a-f0-9]{64}$/), confirmation }, CREATE_ANNOTATIONS, args => saveVersion(client, args));
253
+ register('cpi_deploy_iflow', 'Deploy selected iFlow version',
254
+ 'Deploy active or an exact retained version once. Report build/task and fresh requested-version runtime evidence separately; prior STARTED state is not success. Zero wait initiates without polling. Continue by task ID, never redeploy on timeout. Exact confirmation: DEPLOY IFLOW <iflow_id>.',
255
+ { iflow_id: idSchema, version: cpiVersionSchema, wait_seconds: waitSchema, confirmation }, CREATE_ANNOTATIONS,
256
+ args => { requireConfirmation(args.confirmation, `DEPLOY IFLOW ${args.iflow_id}`); return deployCpiArtifact(client, { ...args, artifact_id: args.iflow_id }, { pollIntervalMs }); });
257
+ register('cpi_deploy_artifact', 'Deploy selected shared CPI artifact version',
258
+ 'Deploy a selected value mapping, script collection or message mapping once. Require fresh exact-version runtime evidence, not an already STARTED artifact. Zero wait does not poll. Exact confirmation: DEPLOY ARTIFACT <artifact_id>.',
259
+ { type: z.enum(Object.keys(artifactTypes)), artifact_id: idSchema, version: cpiVersionSchema, wait_seconds: waitSchema, confirmation }, CREATE_ANNOTATIONS,
260
+ args => { requireConfirmation(args.confirmation, `DEPLOY ARTIFACT ${args.artifact_id}`); return deployCpiArtifact(client, args, { pollIntervalMs }); });
261
+ register('cpi_get_deployment_task', 'Observe CPI deployment task',
262
+ 'Read the documented build task once, optionally with runtime evidence. Same-runtime continuation retains the original pre-deploy baseline. Unknown baselines/versions cannot prove success; no deployment is initiated.',
263
+ { task_id: taskSchema, iflow_id: idSchema.optional(), expected_version: namedVersion.optional() }, READ_ONLY_ANNOTATIONS,
264
+ async ({ task_id, iflow_id, expected_version }) => {
265
+ const cached = tasks.get(client)?.get(task_id);
266
+ if (cached && (iflow_id !== undefined && iflow_id !== cached.artifact_id || expected_version !== undefined && expected_version !== cached.requested_version)) {
267
+ throw new IntegrationError('Deployment task identity/version differs from its recorded request');
268
+ }
269
+ const context = cached ?? { task_id, artifact_id: iflow_id ?? null, iflow_id: iflow_id ?? null, requested_version: expected_version ?? null,
270
+ requested_selector: null, pre_deploy_runtime: null, task_required: true, deployment_accepted: null };
271
+ return observe(client, context, Date.now() + 30000);
272
+ });
273
+ }
@@ -0,0 +1,48 @@
1
+ // Certificate validity metadata only: no trust-chain or connectivity assessment.
2
+ import { z } from 'zod';
3
+ import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
4
+ import { READ_ONLY_ANNOTATIONS, top } from './integration-common.mjs';
5
+ import { IntegrationError } from './integration-http.mjs';
6
+
7
+ const KEYSTORES = ['system', 'backup_admin_system', 'KeyRenewal'];
8
+ function date(value) {
9
+ if (typeof value !== 'string' || !value.trim()) return null;
10
+ const match = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
11
+ const millis = match ? Number(match[1]) : Date.parse(value);
12
+ return Number.isFinite(millis) && !Number.isNaN(new Date(millis).getTime()) ? new Date(millis).toISOString() : null;
13
+ }
14
+ export function evaluateCertificate(entry, { warning_days = 30, evaluated_at = new Date().toISOString() } = {}) {
15
+ const before = date(entry.ValidNotBefore), after = date(entry.ValidNotAfter);
16
+ const now = Date.parse(evaluated_at);
17
+ if (!Number.isInteger(warning_days) || warning_days < 1 || warning_days > 365 || !Number.isFinite(now)) throw new IntegrationError('Invalid certificate evaluation options');
18
+ let validity = 'unknown';
19
+ if (before && after && Date.parse(before) <= Date.parse(after)) {
20
+ if (now > Date.parse(after)) validity = 'expired';
21
+ else if (now < Date.parse(before)) validity = 'not_yet_valid';
22
+ else validity = Date.parse(after) - now <= warning_days * 86400000 ? 'expiring' : 'valid';
23
+ }
24
+ return {
25
+ ...Object.fromEntries(['Alias', 'Type', 'KeyType'].filter(key => typeof entry[key] === 'string').map(key => [key, entry[key]])),
26
+ ValidNotBefore: before, ValidNotAfter: after, evaluated_at: new Date(now).toISOString(),
27
+ days_remaining: validity === 'unknown' ? null : (Date.parse(after) - now) / 86400000,
28
+ validity
29
+ };
30
+ }
31
+ export async function readCertificateHealth(client, { keystore = 'system', warning_days = 30, top = 100, cursor, deadline, evaluated_at } = {}) {
32
+ if (!KEYSTORES.includes(keystore) || !Number.isInteger(warning_days) || warning_days < 1 || warning_days > 365 || !Number.isInteger(top) || top < 1 || top > 200) throw new IntegrationError('Invalid certificate health options');
33
+ const pager = createCollectionPager(client, { root: '/api/v1' });
34
+ const scope = 'certificate_health';
35
+ const context = cursor ? pager.cursorContext(cursor, { scope }) : { evaluated_at: evaluated_at ?? new Date().toISOString() };
36
+ if (evaluated_at && evaluated_at !== context.evaluated_at) throw new IntegrationError('Certificate evaluation time does not match cursor');
37
+ // Validate evaluation inputs even for an empty inventory.
38
+ evaluateCertificate({}, { warning_days, evaluated_at: context.evaluated_at });
39
+ const page = await pager.page({ path: '/KeystoreEntries', query: { keystoreName: keystore }, mode: 'local', top, cursor, deadline, scope,
40
+ context: { ...context, warning_days }, project: entry => evaluateCertificate(entry, { warning_days, evaluated_at: context.evaluated_at }) });
41
+ return { ...page, keystore, warning_days, evaluated_at: context.evaluated_at, assessment: 'certificate_validity_only' };
42
+ }
43
+ export function registerCpiSecurityTools(register, client) {
44
+ register('cpi_get_certificate_health', 'Get CPI certificate health',
45
+ 'Inspect certificate validity metadata only; does not verify trust chains or connectivity.',
46
+ { keystore: z.enum(KEYSTORES).default('system'), warning_days: z.number().int().min(1).max(365).default(30), top: top(200, 100), cursor: cursorSchema },
47
+ READ_ONLY_ANNOTATIONS, args => readCertificateHealth(client, args));
48
+ }