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
package/src/iflow-bundle.mjs
CHANGED
|
@@ -2,11 +2,29 @@
|
|
|
2
2
|
// Pure: no network. Manifest import headers follow FlashPipe's tenant-verified
|
|
3
3
|
// test artifact (Apache-2.0, see NOTICE).
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
|
-
import {
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { strFromU8, strToU8, Unzip, UnzipInflate, Zip, ZipDeflate } from 'fflate';
|
|
6
7
|
import { IntegrationError } from './integration-http.mjs';
|
|
7
8
|
|
|
8
9
|
export const IFLOW_TEMPLATES = Object.freeze(['https-request-reply-http']);
|
|
9
10
|
|
|
11
|
+
/** Content identity independent of ZIP ordering, compression and timestamps. */
|
|
12
|
+
export function bundleDigest(files) {
|
|
13
|
+
const hash = createHash('sha256');
|
|
14
|
+
const entries = [...files].map(([path, bytes]) => [Buffer.from(path, 'utf8'), bytes]);
|
|
15
|
+
entries.sort(([a], [b]) => Buffer.compare(a, b));
|
|
16
|
+
const length = Buffer.allocUnsafe(4);
|
|
17
|
+
for (const [path, bytes] of entries) {
|
|
18
|
+
for (const value of [path, bytes]) {
|
|
19
|
+
if (value.byteLength > 0xffffffff) throw new IntegrationError('Bundle member exceeds digest length limit');
|
|
20
|
+
length.writeUInt32BE(value.byteLength);
|
|
21
|
+
hash.update(length);
|
|
22
|
+
hash.update(value);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return hash.digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
10
28
|
const IMPORT_PACKAGE = "com.sap.esb.application.services.cxf.interceptor,com.sap.esb.security,com.sap.it.op.agent.api,com.sap.it.op.agent.collector.camel,com.sap.it.op.agent.collector.cxf,com.sap.it.op.agent.mpl,javax.jms,javax.jws,javax.wsdl,javax.xml.bind.annotation,javax.xml.namespace,javax.xml.ws,org.apache.camel;version=\"2.8\",org.apache.camel.builder;version=\"2.8\",org.apache.camel.builder.xml;version=\"2.8\",org.apache.camel.component.cxf,org.apache.camel.model;version=\"2.8\",org.apache.camel.processor;version=\"2.8\",org.apache.camel.processor.aggregate;version=\"2.8\",org.apache.camel.spring.spi;version=\"2.8\",org.apache.commons.logging,org.apache.cxf.binding,org.apache.cxf.binding.soap,org.apache.cxf.binding.soap.spring,org.apache.cxf.bus,org.apache.cxf.bus.resource,org.apache.cxf.bus.spring,org.apache.cxf.buslifecycle,org.apache.cxf.catalog,org.apache.cxf.configuration.jsse;version=\"2.5\",org.apache.cxf.configuration.spring,org.apache.cxf.endpoint,org.apache.cxf.headers,org.apache.cxf.interceptor,org.apache.cxf.management.counters;version=\"2.5\",org.apache.cxf.message,org.apache.cxf.phase,org.apache.cxf.resource,org.apache.cxf.service.factory,org.apache.cxf.service.model,org.apache.cxf.transport,org.apache.cxf.transport.common.gzip,org.apache.cxf.transport.http,org.apache.cxf.transport.http.policy,org.apache.cxf.workqueue,org.apache.cxf.ws.rm.persistence,org.apache.cxf.wsdl11,org.osgi.framework;version=\"1.6.0\",org.slf4j;version=\"1.6\",org.springframework.beans.factory.config;version=\"3.0\",com.sap.esb.camel.security.cms,org.apache.camel.spi,com.sap.esb.webservice.audit.log,com.sap.esb.camel.endpoint.configurator.api,com.sap.esb.camel.jdbc.idempotency.reorg,javax.sql,org.apache.camel.processor.idempotent.jdbc,org.osgi.service.blueprint;version=\"[1.0.0,2.0.0)\"";
|
|
11
29
|
const IMPORT_SERVICE = "com.sap.esb.webservice.audit.log.AuditLogger,com.sap.esb.security.KeyManagerFactory;multiple:=false,com.sap.esb.security.TrustManagerFactory;multiple:=false,javax.sql.DataSource;multiple:=false;filter=\"(dataSourceName=default)\",org.apache.cxf.ws.rm.persistence.RMStore;multiple:=false,com.sap.esb.camel.security.cms.SignatureSplitter;multiple:=false";
|
|
12
30
|
|
|
@@ -263,20 +281,290 @@ export function buildIflowBundle({
|
|
|
263
281
|
files[PARAMETERS_PROP] = strToU8(propertiesText(merged));
|
|
264
282
|
files[PARAMETERS_PROPDEF] = strToU8(propdefText(parameterEntries.map(([key]) => key)));
|
|
265
283
|
}
|
|
266
|
-
return
|
|
284
|
+
return writeBundle(Object.entries(files));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const MAX_ARCHIVE_BYTES = 20 * 1024 * 1024;
|
|
288
|
+
const MAX_MEMBER_BYTES = 20 * 1024 * 1024;
|
|
289
|
+
const MAX_EXPANDED_BYTES = 100 * 1024 * 1024;
|
|
290
|
+
const MAX_MEMBERS = 2000;
|
|
291
|
+
// Limit each inflate call, not just its accumulated output. A small compressed
|
|
292
|
+
// chunk bounds fflate's temporary output even when the declared size is false.
|
|
293
|
+
const INFLATE_CHUNK_BYTES = 1024;
|
|
294
|
+
const ZIP_CHUNK_BYTES = 64 * 1024;
|
|
295
|
+
const EMPTY_BYTES = new Uint8Array(0);
|
|
296
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
297
|
+
|
|
298
|
+
function archiveError(message) {
|
|
299
|
+
return new IntegrationError(`iFlow bundle zip: ${message}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function validateMemberPath(path, seen) {
|
|
303
|
+
if (typeof path !== 'string' || !path || path.includes('\0') || path.includes('\\')
|
|
304
|
+
|| path.startsWith('/') || /^[A-Za-z]:/.test(path)
|
|
305
|
+
|| Buffer.byteLength(path, 'utf8') > 65535 || strFromU8(strToU8(path)) !== path) {
|
|
306
|
+
throw archiveError('unsafe member path');
|
|
307
|
+
}
|
|
308
|
+
const segments = path.endsWith('/') ? path.slice(0, -1).split('/') : path.split('/');
|
|
309
|
+
if (segments.some(part => !part || part === '.' || part === '..')) throw archiveError('unsafe member path');
|
|
310
|
+
if (seen.has(path)) throw archiveError('duplicate member path');
|
|
311
|
+
seen.add(path);
|
|
312
|
+
if (seen.size > MAX_MEMBERS) throw archiveError(`more than ${MAX_MEMBERS} members`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function validatePathRelationships(paths) {
|
|
316
|
+
for (const path of paths) {
|
|
317
|
+
if (path.endsWith('/') && paths.has(path.slice(0, -1))) throw archiveError('conflicting member paths');
|
|
318
|
+
for (let slash = path.indexOf('/'); slash >= 0; slash = path.indexOf('/', slash + 1)) {
|
|
319
|
+
if (paths.has(path.slice(0, slash))) throw archiveError('file used as a member directory');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function checkExtraFields(bytes, start, length) {
|
|
325
|
+
const end = start + length;
|
|
326
|
+
while (start < end) {
|
|
327
|
+
if (start + 4 > end) throw archiveError('malformed extra field');
|
|
328
|
+
const kind = bytes[start] | (bytes[start + 1] << 8);
|
|
329
|
+
const size = bytes[start + 2] | (bytes[start + 3] << 8);
|
|
330
|
+
start += 4 + size;
|
|
331
|
+
if (start > end) throw archiveError('truncated extra field');
|
|
332
|
+
// Neither ZIP64 nor an alternate Unicode filename is needed for bounded
|
|
333
|
+
// content bundles. Reject rather than letting another importer choose a
|
|
334
|
+
// different size, offset or path than the one checked here.
|
|
335
|
+
if (kind === 1 || kind === 0x7075) throw archiveError('unsupported ZIP64 or alternate filename metadata');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Check archive structure without trusting its expanded-size declarations. */
|
|
340
|
+
function inspectArchive(bytes) {
|
|
341
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
342
|
+
const u16 = offset => view.getUint16(offset, true);
|
|
343
|
+
const u32 = offset => view.getUint32(offset, true);
|
|
344
|
+
let end = -1;
|
|
345
|
+
for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset -= 1) {
|
|
346
|
+
if (u32(offset) === 0x06054b50 && offset + 22 + u16(offset + 20) === bytes.length) {
|
|
347
|
+
end = offset;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (end < 0) throw archiveError('missing or truncated end record');
|
|
352
|
+
const count = u16(end + 10);
|
|
353
|
+
const centralSize = u32(end + 12);
|
|
354
|
+
const centralOffset = u32(end + 16);
|
|
355
|
+
if (u16(end + 4) || u16(end + 6) || u16(end + 8) !== count
|
|
356
|
+
|| count > MAX_MEMBERS || centralOffset + centralSize !== end) {
|
|
357
|
+
throw archiveError('invalid, split or oversized central directory');
|
|
358
|
+
}
|
|
359
|
+
const entries = [];
|
|
360
|
+
const names = new Set();
|
|
361
|
+
let offset = centralOffset;
|
|
362
|
+
for (let index = 0; index < count; index += 1) {
|
|
363
|
+
if (offset + 46 > end || u32(offset) !== 0x02014b50) throw archiveError('malformed central directory');
|
|
364
|
+
const flags = u16(offset + 8);
|
|
365
|
+
const compression = u16(offset + 10);
|
|
366
|
+
const crc = u32(offset + 16);
|
|
367
|
+
const compressedSize = u32(offset + 20);
|
|
368
|
+
const originalSize = u32(offset + 24);
|
|
369
|
+
const nameLength = u16(offset + 28);
|
|
370
|
+
const extraLength = u16(offset + 30);
|
|
371
|
+
const commentLength = u16(offset + 32);
|
|
372
|
+
const localOffset = u32(offset + 42);
|
|
373
|
+
const next = offset + 46 + nameLength + extraLength + commentLength;
|
|
374
|
+
if (next > end || u16(offset + 34) || (flags & ~0x080e) || ![0, 8].includes(compression)
|
|
375
|
+
|| (compression === 0 && (flags & 6)) || compressedSize > MAX_ARCHIVE_BYTES
|
|
376
|
+
|| (compression === 8 && compressedSize < 2)
|
|
377
|
+
|| originalSize > MAX_MEMBER_BYTES || localOffset >= centralOffset) {
|
|
378
|
+
throw archiveError('unsupported or oversized member');
|
|
379
|
+
}
|
|
380
|
+
const nameBytes = bytes.subarray(offset + 46, offset + 46 + nameLength);
|
|
381
|
+
const name = flags & 0x0800 ? utf8Decoder.decode(nameBytes) : strFromU8(nameBytes, true);
|
|
382
|
+
validateMemberPath(name, names);
|
|
383
|
+
checkExtraFields(bytes, offset + 46 + nameLength, extraLength);
|
|
384
|
+
entries.push({ name, nameBytes, flags, compression, crc, compressedSize, originalSize, localOffset });
|
|
385
|
+
offset = next;
|
|
386
|
+
}
|
|
387
|
+
if (offset !== end) throw archiveError('central directory length mismatch');
|
|
388
|
+
validatePathRelationships(names);
|
|
389
|
+
entries.sort((a, b) => a.localOffset - b.localOffset);
|
|
390
|
+
offset = 0;
|
|
391
|
+
for (const entry of entries) {
|
|
392
|
+
const { localOffset, nameBytes, flags, compression, crc, compressedSize, originalSize } = entry;
|
|
393
|
+
if (localOffset !== offset || offset + 30 > centralOffset || u32(offset) !== 0x04034b50) {
|
|
394
|
+
throw archiveError('missing, overlapping or unindexed local member');
|
|
395
|
+
}
|
|
396
|
+
const nameLength = u16(offset + 26);
|
|
397
|
+
const extraLength = u16(offset + 28);
|
|
398
|
+
const dataOffset = offset + 30 + nameLength + extraLength;
|
|
399
|
+
if (dataOffset + compressedSize > centralOffset || flags !== u16(offset + 6)
|
|
400
|
+
|| compression !== u16(offset + 8) || nameLength !== nameBytes.length) {
|
|
401
|
+
throw archiveError('local and central member mismatch');
|
|
402
|
+
}
|
|
403
|
+
for (let index = 0; index < nameLength; index += 1) {
|
|
404
|
+
if (bytes[offset + 30 + index] !== nameBytes[index]) throw archiveError('local and central path mismatch');
|
|
405
|
+
}
|
|
406
|
+
checkExtraFields(bytes, offset + 30 + nameLength, extraLength);
|
|
407
|
+
for (const [field, expected] of [[14, crc], [18, compressedSize], [22, originalSize]]) {
|
|
408
|
+
const actual = u32(offset + field);
|
|
409
|
+
if (actual !== expected && (!(flags & 8) || actual !== 0)) throw archiveError('local member size or checksum mismatch');
|
|
410
|
+
}
|
|
411
|
+
entry.dataOffset = dataOffset;
|
|
412
|
+
offset = dataOffset + compressedSize;
|
|
413
|
+
if (flags & 8) {
|
|
414
|
+
// A signed or unsigned classic data descriptor must agree with the
|
|
415
|
+
// central record. Try both when a CRC happens to equal the signature.
|
|
416
|
+
const descriptor = [offset, offset + 4].find(start =>
|
|
417
|
+
(start === offset || (offset + 4 <= centralOffset && u32(offset) === 0x08074b50))
|
|
418
|
+
&& start + 12 <= centralOffset && u32(start) === crc
|
|
419
|
+
&& u32(start + 4) === compressedSize && u32(start + 8) === originalSize);
|
|
420
|
+
if (descriptor === undefined) throw archiveError('missing or invalid data descriptor');
|
|
421
|
+
offset = descriptor + 12;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (offset !== centralOffset) throw archiveError('unindexed or truncated member data');
|
|
425
|
+
return { entries, centralOffset };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const crcTable = new Uint32Array(256);
|
|
429
|
+
for (let index = 0; index < crcTable.length; index += 1) {
|
|
430
|
+
let value = index;
|
|
431
|
+
for (let bit = 0; bit < 8; bit += 1) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
|
|
432
|
+
crcTable[index] = value;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function joinChunks(chunks, size) {
|
|
436
|
+
if (chunks.length === 1) return chunks[0];
|
|
437
|
+
const result = new Uint8Array(size);
|
|
438
|
+
let offset = 0;
|
|
439
|
+
for (const chunk of chunks) {
|
|
440
|
+
result.set(chunk, offset);
|
|
441
|
+
offset += chunk.length;
|
|
442
|
+
}
|
|
443
|
+
return result;
|
|
267
444
|
}
|
|
268
445
|
|
|
269
|
-
/** Unzip a bundle into path → bytes. */
|
|
446
|
+
/** Unzip a bounded, structurally checked bundle into path → exact bytes. */
|
|
270
447
|
export function readBundle(bytes) {
|
|
448
|
+
if (!(bytes instanceof Uint8Array)) throw archiveError('archive must be bytes');
|
|
449
|
+
if (bytes.length > MAX_ARCHIVE_BYTES) throw archiveError('compressed archive exceeds 20 MiB');
|
|
450
|
+
let activeStream;
|
|
271
451
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
452
|
+
const { entries } = inspectArchive(bytes);
|
|
453
|
+
const files = new Map();
|
|
454
|
+
let total = 0;
|
|
455
|
+
let discovered = 0;
|
|
456
|
+
let completed = 0;
|
|
457
|
+
const unzip = new Unzip(file => {
|
|
458
|
+
if (activeStream) throw archiveError('incomplete preceding member');
|
|
459
|
+
activeStream = file;
|
|
460
|
+
const entry = entries[discovered++];
|
|
461
|
+
if (!entry || file.name !== entry.name) throw archiveError('unexpected streamed member');
|
|
462
|
+
const chunks = [];
|
|
463
|
+
let size = 0;
|
|
464
|
+
let crc = 0xffffffff;
|
|
465
|
+
file.ondata = (error, chunk, final) => {
|
|
466
|
+
if (error) throw error instanceof IntegrationError ? error : archiveError('invalid or truncated compressed member');
|
|
467
|
+
size += chunk.length;
|
|
468
|
+
total += chunk.length;
|
|
469
|
+
if (size > MAX_MEMBER_BYTES || total > MAX_EXPANDED_BYTES) throw archiveError('actual expanded byte limit exceeded');
|
|
470
|
+
if (size > entry.originalSize) throw archiveError('actual member size exceeds declared size');
|
|
471
|
+
for (const byte of chunk) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8);
|
|
472
|
+
if (chunk.length) chunks.push(chunk);
|
|
473
|
+
if (final) {
|
|
474
|
+
if (size !== entry.originalSize || ((crc ^ 0xffffffff) >>> 0) !== entry.crc) {
|
|
475
|
+
throw archiveError('member size or checksum mismatch');
|
|
476
|
+
}
|
|
477
|
+
if (entry.name.endsWith('/') && size) throw archiveError('nonempty directory member');
|
|
478
|
+
files.set(entry.name, joinChunks(chunks, size));
|
|
479
|
+
chunks.length = 0;
|
|
480
|
+
activeStream = undefined;
|
|
481
|
+
completed += 1;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
file.start();
|
|
485
|
+
});
|
|
486
|
+
unzip.register(UnzipInflate);
|
|
487
|
+
const pushRange = (start, end) => {
|
|
488
|
+
for (let offset = start; offset < end; offset += INFLATE_CHUNK_BYTES) {
|
|
489
|
+
unzip.push(bytes.subarray(offset, Math.min(end, offset + INFLATE_CHUNK_BYTES)), false);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
for (const entry of entries) {
|
|
493
|
+
if (entry.flags & 8) {
|
|
494
|
+
// Supply the already verified descriptor sizes to the streaming
|
|
495
|
+
// decoder. This prevents signature-like binary data from being
|
|
496
|
+
// mistaken for a descriptor, without copying the member payload.
|
|
497
|
+
const header = new Uint8Array(bytes.subarray(entry.localOffset, entry.localOffset + 30));
|
|
498
|
+
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
|
|
499
|
+
view.setUint16(6, entry.flags & ~8, true);
|
|
500
|
+
view.setUint32(14, entry.crc, true);
|
|
501
|
+
view.setUint32(18, entry.compressedSize, true);
|
|
502
|
+
view.setUint32(22, entry.originalSize, true);
|
|
503
|
+
unzip.push(header, false);
|
|
504
|
+
pushRange(entry.localOffset + 30, entry.dataOffset + entry.compressedSize);
|
|
505
|
+
} else {
|
|
506
|
+
pushRange(entry.localOffset, entry.dataOffset + entry.compressedSize);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// The central directory was checked above, not streamed as local records:
|
|
510
|
+
// arbitrary ZIP comments must never be scanned for apparent file headers.
|
|
511
|
+
// One sentinel byte also lets fflate discover a final zero-length member.
|
|
512
|
+
unzip.push(Uint8Array.of(0), true);
|
|
513
|
+
if (discovered !== entries.length || completed !== entries.length) throw archiveError('incomplete member streams');
|
|
514
|
+
return files;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
activeStream?.terminate();
|
|
517
|
+
if (error instanceof IntegrationError) throw error;
|
|
518
|
+
throw archiveError('not a readable zip archive');
|
|
275
519
|
}
|
|
276
520
|
}
|
|
277
521
|
|
|
522
|
+
/** Write only archives accepted by the same path, count and byte budgets. */
|
|
278
523
|
export function writeBundle(files) {
|
|
279
|
-
|
|
524
|
+
const entries = [];
|
|
525
|
+
const names = new Set();
|
|
526
|
+
let expandedSize = 0;
|
|
527
|
+
for (const [name, bytes] of files) {
|
|
528
|
+
validateMemberPath(name, names);
|
|
529
|
+
if (!(bytes instanceof Uint8Array)) throw archiveError('member content must be bytes');
|
|
530
|
+
if (bytes.length > MAX_MEMBER_BYTES || (name.endsWith('/') && bytes.length)) throw archiveError('invalid member size');
|
|
531
|
+
expandedSize += bytes.length;
|
|
532
|
+
if (expandedSize > MAX_EXPANDED_BYTES) throw archiveError('actual expanded byte limit exceeded');
|
|
533
|
+
entries.push([name, bytes]);
|
|
534
|
+
}
|
|
535
|
+
validatePathRelationships(names);
|
|
536
|
+
const chunks = [];
|
|
537
|
+
let size = 0;
|
|
538
|
+
const zip = new Zip((error, chunk) => {
|
|
539
|
+
if (error) throw error instanceof IntegrationError ? error : archiveError('unable to write archive');
|
|
540
|
+
size += chunk.length;
|
|
541
|
+
if (size > MAX_ARCHIVE_BYTES) throw archiveError('compressed archive exceeds 20 MiB');
|
|
542
|
+
if (chunk.length) chunks.push(chunk);
|
|
543
|
+
});
|
|
544
|
+
try {
|
|
545
|
+
for (const [name, bytes] of entries) {
|
|
546
|
+
const file = new ZipDeflate(name);
|
|
547
|
+
// Zip retains member metadata until its central directory is written.
|
|
548
|
+
// Do not retain every finished compressor and its working buffers too.
|
|
549
|
+
const member = { filename: name, compression: file.compression, flag: file.flag };
|
|
550
|
+
file.ondata = (error, chunk, final) => {
|
|
551
|
+
member.size = file.size;
|
|
552
|
+
member.crc = file.crc;
|
|
553
|
+
member.ondata(error, chunk, final);
|
|
554
|
+
};
|
|
555
|
+
zip.add(member);
|
|
556
|
+
for (let offset = 0; offset < bytes.length; offset += ZIP_CHUNK_BYTES) {
|
|
557
|
+
file.push(bytes.subarray(offset, Math.min(bytes.length, offset + ZIP_CHUNK_BYTES)), false);
|
|
558
|
+
}
|
|
559
|
+
file.push(EMPTY_BYTES, true);
|
|
560
|
+
}
|
|
561
|
+
zip.end();
|
|
562
|
+
return joinChunks(chunks, size);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
zip.terminate();
|
|
565
|
+
if (error instanceof IntegrationError) throw error;
|
|
566
|
+
throw archiveError('unable to write archive');
|
|
567
|
+
}
|
|
280
568
|
}
|
|
281
569
|
|
|
282
570
|
export { strFromU8, strToU8, MANIFEST_PATH, PARAMETERS_PROP, PARAMETERS_PROPDEF };
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Native ANS configuration inspection: rule evidence is not collection or delivery evidence.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { IntegrationError } from './integration-http.mjs';
|
|
4
|
+
import { READ_ONLY_ANNOTATIONS, OVERWRITE_ANNOTATIONS, requireConfirmation, top, entity } from './integration-common.mjs';
|
|
5
|
+
import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
|
|
6
|
+
import { canonicalDigest } from './integration-state.mjs';
|
|
7
|
+
|
|
8
|
+
const ROOT = '/cf/configuration/v1';
|
|
9
|
+
const PAGE_SIZE = 200;
|
|
10
|
+
const MAX_REFERENCES = 1000;
|
|
11
|
+
const MAX_OUTPUT = 60 * 1024;
|
|
12
|
+
const name = z.string().min(1).max(256).regex(/^[^\x00-\x1f\x7f]+$/).refine(value => value.trim().length > 0 && value !== '.' && value !== '..', 'Invalid ANS identity');
|
|
13
|
+
const names = z.array(name).min(1).max(20);
|
|
14
|
+
const digest = z.string().regex(/^[a-f0-9]{64}$/).optional();
|
|
15
|
+
const PREDICATES = ['CONTAINS', 'DOES_NOT_CONTAIN', 'STARTS_WITH', 'DOES_NOT_START_WITH', 'ENDS_WITH', 'DOES_NOT_END_WITH', 'EQUALS', 'NOT_EQUALS', 'ANY', 'NO_VALUE', 'LESS_THAN', 'GREATER_THAN', 'NUMBER_EQUALS'];
|
|
16
|
+
const CTMS_EVENTS = new Set(['TmsImportStarted', 'TmsImportFinished', 'TmsTransportRequestAdded', 'TmsNodeImportJobDeactivated', 'TmsStorageQuotaUsage']);
|
|
17
|
+
const ruleInputs = { subscription_names: names.optional(), node_names: names.optional() };
|
|
18
|
+
const conditionInput = z.object({ name, propertyKey: name, predicate: z.enum(PREDICATES), propertyValue: z.string().max(4096).optional(), mandatory: z.boolean().optional() }).strict();
|
|
19
|
+
const validName = value => name.safeParse(value).success;
|
|
20
|
+
const safeScopeValue = value => typeof value === 'string' && /^[A-Za-z0-9_. -]{1,128}$/.test(value) && !/^[A-Za-z0-9_-]{40,}$/.test(value);
|
|
21
|
+
const strings = value => Array.isArray(value) && value.every(validName) && new Set(value).size === value.length;
|
|
22
|
+
const text = value => typeof value === 'string' ? value.slice(0, 256) : null;
|
|
23
|
+
const issue = (code, severity = 'needs_review', reference) => ({ code, severity, ...(reference ? { reference: text(reference) } : {}) });
|
|
24
|
+
const outcome = findings => findings.some(row => row.severity === 'blocked') ? 'blocked' : findings.length ? 'needs_review' : 'rules_validated';
|
|
25
|
+
const sourceFailure = error => ({ result: error?.status === 401 ? 'unauthenticated' : error?.status === 403 ? 'forbidden' : error?.status === 404 || error?.status === 501 ? 'unavailable' : 'error', status: Number.isInteger(error?.status) ? error.status : null, ...(error?.code === 'INTEGRATION_DEADLINE' ? { stop_reason: 'deadline' } : {}) });
|
|
26
|
+
|
|
27
|
+
function projection(kind, row) {
|
|
28
|
+
const base = { name: text(row.name) };
|
|
29
|
+
if (kind === 'action') return { ...base, type: text(row.type), state: text(row.state), fallbackAction: text(row.fallbackAction), enableDeliveryStatus: typeof row.enableDeliveryStatus === 'boolean' ? row.enableDeliveryStatus : null };
|
|
30
|
+
if (kind === 'condition') return { ...base, propertyKey: text(row.propertyKey), predicate: text(row.predicate), ...(typeof row.mandatory === 'boolean' ? { mandatory: row.mandatory } : {}), property_value: row.propertyValue === undefined ? 'absent' : 'withheld', configuration_digest: canonicalDigest(row) };
|
|
31
|
+
return { ...base, state: text(row.state), snoozeTimestamp: Number.isFinite(row.snoozeTimestamp) ? row.snoozeTimestamp : null,
|
|
32
|
+
conditions: strings(row.conditions) ? row.conditions.map(text).slice(0, 50) : null, actions: strings(row.actions) ? row.actions.map(text).slice(0, 50) : null,
|
|
33
|
+
references_truncated: (row.conditions?.length ?? 0) > 50 || (row.actions?.length ?? 0) > 50, configuration_digest: canonicalDigest(row) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function decodePage(raw, page) {
|
|
37
|
+
const meta = raw?.metadata;
|
|
38
|
+
const integer = value => Number.isSafeInteger(value) && value >= 0;
|
|
39
|
+
if (!Array.isArray(raw?.results) || !meta || !['page', 'pageSize', 'totalPages', 'totalResultsCount'].every(key => integer(meta[key]))
|
|
40
|
+
|| meta.page !== page || meta.pageSize !== PAGE_SIZE || meta.totalPages !== Math.ceil(meta.totalResultsCount / PAGE_SIZE) && !(meta.totalResultsCount === 0 && meta.totalPages === 1)
|
|
41
|
+
|| raw.results.length !== Math.min(PAGE_SIZE, Math.max(0, meta.totalResultsCount - page * PAGE_SIZE))
|
|
42
|
+
|| raw.results.some(row => !row || typeof row !== 'object' || Array.isArray(row) || !validName(row.name))
|
|
43
|
+
|| new Set(raw.results.map(row => row.name)).size !== raw.results.length) throw new IntegrationError('ANS collection response has invalid paging metadata or identities');
|
|
44
|
+
return { rows: raw.results, total: meta.totalResultsCount };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createReader(client) {
|
|
48
|
+
return createCollectionPager(client, { root: ROOT, request: async (method, path, options) => {
|
|
49
|
+
const skip = Number(options.query?.$skip ?? 0);
|
|
50
|
+
const page = Math.floor(skip / PAGE_SIZE);
|
|
51
|
+
const raw = await client.request('GET', `${ROOT}${path}`, { query: { page, pageSize: PAGE_SIZE }, unwrap: false, deadline: options.deadline });
|
|
52
|
+
const decoded = decodePage(raw, page);
|
|
53
|
+
return { d: { results: decoded.rows.slice(skip % PAGE_SIZE), __count: String(decoded.total) } };
|
|
54
|
+
} });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function named(client, kind, value, deadline) {
|
|
58
|
+
const result = await client.request('GET', `${ROOT}/${kind}/${encodeURIComponent(value)}`, { expect: [200, 404], withStatus: true, unwrap: false, deadline });
|
|
59
|
+
if (result.status === 404) return null;
|
|
60
|
+
const row = result.data;
|
|
61
|
+
if (!row || typeof row !== 'object' || Array.isArray(row) || row.name !== value) throw new IntegrationError('ANS named response identity does not match the requested resource');
|
|
62
|
+
return entity(row);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolver(client, deadline) {
|
|
66
|
+
const cache = new Map();
|
|
67
|
+
return (kind, value) => {
|
|
68
|
+
if (!validName(value)) return Promise.reject(new IntegrationError('ANS reference identity is invalid'));
|
|
69
|
+
const key = `${kind}:${value}`;
|
|
70
|
+
if (!cache.has(key)) {
|
|
71
|
+
if (cache.size >= MAX_REFERENCES) return Promise.reject(new IntegrationError('ANS reference inspection exceeded its bounded source limit'));
|
|
72
|
+
cache.set(key, named(client, kind, value, deadline));
|
|
73
|
+
}
|
|
74
|
+
return cache.get(key);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A mandatory exact condition can constrain an otherwise broad OR group. Explicit optional
|
|
79
|
+
// mixtures have no locally established truth table and must never be called a verified match.
|
|
80
|
+
function exactScope(conditions, key) {
|
|
81
|
+
const group = conditions.filter(row => row.propertyKey === key);
|
|
82
|
+
const mandatory = group.filter(row => row.mandatory === true);
|
|
83
|
+
const candidates = conditions.some(row => row.mandatory === true) ? mandatory : group;
|
|
84
|
+
if (!candidates.length || candidates.some(row => row.predicate !== 'EQUALS' || !safeScopeValue(row.propertyValue))) return null;
|
|
85
|
+
const values = [...new Set(candidates.map(row => row.propertyValue))];
|
|
86
|
+
if (mandatory.length && values.length !== 1) return null;
|
|
87
|
+
return values;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function inspectSubscription(subscription, resolve, { node_names, now = Date.now() } = {}) {
|
|
91
|
+
const findings = [];
|
|
92
|
+
let complete = true;
|
|
93
|
+
const conditions = [];
|
|
94
|
+
const actions = [];
|
|
95
|
+
if (subscription.state === 'DISABLED') findings.push(issue('subscription_disabled', 'blocked'));
|
|
96
|
+
else if (subscription.state !== 'ENABLED') findings.push(issue('subscription_state_unknown'));
|
|
97
|
+
const snooze = subscription.snoozeTimestamp;
|
|
98
|
+
// Normalize Unix seconds; tolerate epoch-millisecond representations without exposing dates.
|
|
99
|
+
const snoozeMillis = typeof snooze === 'number' && snooze < 100_000_000_000 ? snooze * 1000 : snooze;
|
|
100
|
+
const snoozed = typeof snoozeMillis === 'number' && Number.isFinite(snoozeMillis) && snoozeMillis > now;
|
|
101
|
+
if (snoozed) findings.push(issue('subscription_snoozed', 'blocked'));
|
|
102
|
+
if (snooze !== undefined && (!Number.isSafeInteger(snooze) || snooze < 0)) findings.push(issue('subscription_snooze_unknown'));
|
|
103
|
+
const resolveRefs = async (kind, refs, target) => {
|
|
104
|
+
if (!strings(refs) || refs.length === 0 || refs.length > 50) { findings.push(issue(`${kind}_references_invalid`, 'blocked')); complete = false; return; }
|
|
105
|
+
for (const ref of refs) {
|
|
106
|
+
try {
|
|
107
|
+
const row = await resolve(kind, ref);
|
|
108
|
+
if (row) target.push(row);
|
|
109
|
+
else findings.push(issue(`${kind}_missing`, 'blocked', ref));
|
|
110
|
+
} catch (error) { complete = false; findings.push({ ...issue(`${kind}_unreadable`, 'needs_review', ref), source_result: sourceFailure(error) }); }
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
await resolveRefs('condition', subscription.conditions, conditions);
|
|
114
|
+
await resolveRefs('action', subscription.actions, actions);
|
|
115
|
+
const visited = new Set();
|
|
116
|
+
async function fallback(row, stack = new Set()) {
|
|
117
|
+
if (stack.has(row.name)) { findings.push(issue('action_fallback_cycle', 'needs_review', row.name)); return; }
|
|
118
|
+
if (visited.has(row.name)) return;
|
|
119
|
+
visited.add(row.name);
|
|
120
|
+
if (row.state === 'DISABLED') findings.push(issue('action_disabled', 'blocked', row.name));
|
|
121
|
+
else if (row.state !== 'ENABLED') findings.push(issue('action_state_unknown', 'needs_review', row.name));
|
|
122
|
+
if (row.fallbackAction !== undefined && row.fallbackAction !== null && row.fallbackAction !== '') {
|
|
123
|
+
try {
|
|
124
|
+
const next = await resolve('action', row.fallbackAction);
|
|
125
|
+
if (!next) findings.push(issue('action_missing', 'blocked', row.fallbackAction));
|
|
126
|
+
else { if (!actions.some(action => action.name === next.name)) actions.push(next); await fallback(next, new Set([...stack, row.name])); }
|
|
127
|
+
} catch (error) { complete = false; findings.push({ ...issue('action_unreadable', 'needs_review', row.fallbackAction), source_result: sourceFailure(error) }); }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const action of [...actions]) await fallback(action);
|
|
131
|
+
for (const row of conditions) {
|
|
132
|
+
if (!validName(row.propertyKey) || !PREDICATES.includes(row.predicate) || row.mandatory !== undefined && typeof row.mandatory !== 'boolean'
|
|
133
|
+
|| !['ANY', 'NO_VALUE'].includes(row.predicate) && typeof row.propertyValue !== 'string'
|
|
134
|
+
|| ['LESS_THAN', 'GREATER_THAN', 'NUMBER_EQUALS'].includes(row.predicate) && (typeof row.propertyValue !== 'string' || row.propertyValue.trim() === '' || !Number.isFinite(Number(row.propertyValue)))) findings.push(issue('condition_unsupported', 'needs_review', row.name));
|
|
135
|
+
}
|
|
136
|
+
if (conditions.some(row => row.mandatory === false)) findings.push(issue('explicit_optional_semantics_unverified'));
|
|
137
|
+
const eventTypes = exactScope(conditions, 'eventType');
|
|
138
|
+
const nodeNames = exactScope(conditions, 'tags.nodeName');
|
|
139
|
+
const nodeIds = exactScope(conditions, 'tags.nodeId');
|
|
140
|
+
const knownEvents = eventTypes?.every(value => CTMS_EVENTS.has(value));
|
|
141
|
+
if (!eventTypes) findings.push(issue('event_scope_unsafe'));
|
|
142
|
+
else if (!knownEvents) findings.push(issue('event_scope_unverified'));
|
|
143
|
+
if (knownEvents && !nodeNames && !nodeIds) findings.push(issue('ctms_node_scope_unsafe'));
|
|
144
|
+
if (node_names && (!nodeNames || nodeNames.some(value => !node_names.includes(value)))) findings.push(issue('requested_node_scope_unverified'));
|
|
145
|
+
if (conditions.some(row => !['eventType', 'tags.nodeName', 'tags.nodeId'].includes(row.propertyKey))) findings.push(issue('additional_condition_scope_unverified'));
|
|
146
|
+
const scoped = !findings.some(row => /scope|semantics|unsupported/.test(row.code));
|
|
147
|
+
return { ...projection('subscription', subscription), snoozed, complete, outcome: outcome(findings), findings,
|
|
148
|
+
conditions: conditions.map(row => projection('condition', row)), actions: actions.map(row => projection('action', row)),
|
|
149
|
+
scope: { event_types: knownEvents ? eventTypes : [], node_names: nodeNames ?? [], node_ids: nodeIds ?? [], verified: scoped } };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function validateNativeRules(client, args = {}, { deadline = Date.now() + 30000 } = {}) {
|
|
153
|
+
args = z.object(ruleInputs).strict().parse(args);
|
|
154
|
+
const items = [];
|
|
155
|
+
const findings = [];
|
|
156
|
+
let complete = true;
|
|
157
|
+
const resolve = resolver(client, deadline);
|
|
158
|
+
const subscriptions = [];
|
|
159
|
+
try {
|
|
160
|
+
if (args.subscription_names) {
|
|
161
|
+
for (const value of new Set(args.subscription_names)) {
|
|
162
|
+
try {
|
|
163
|
+
const row = await resolve('subscription', value);
|
|
164
|
+
if (row) subscriptions.push(row);
|
|
165
|
+
else findings.push({ ...issue('subscription_missing', 'blocked'), subscription: value });
|
|
166
|
+
} catch (error) {
|
|
167
|
+
complete = false;
|
|
168
|
+
findings.push({ ...issue('subscription_unreadable'), subscription: value, source_result: sourceFailure(error) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
let expectedTotal;
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
for (let page = 0; ; page += 1) {
|
|
175
|
+
if (page >= 10) { complete = false; findings.push(issue('subscription_source_limit')); break; }
|
|
176
|
+
const decoded = decodePage(await client.request('GET', `${ROOT}/subscription`, { query: { page, pageSize: PAGE_SIZE }, unwrap: false, deadline }), page);
|
|
177
|
+
if (expectedTotal !== undefined && decoded.total !== expectedTotal || decoded.rows.some(row => seen.has(row.name))) { complete = false; findings.push(issue('subscription_source_changed')); break; }
|
|
178
|
+
expectedTotal = decoded.total;
|
|
179
|
+
decoded.rows.forEach(row => seen.add(row.name));
|
|
180
|
+
subscriptions.push(...decoded.rows);
|
|
181
|
+
if (subscriptions.length >= decoded.total) break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch (error) { complete = false; findings.push({ ...issue('subscriptions_unreadable'), source_result: sourceFailure(error) }); }
|
|
185
|
+
if (!subscriptions.length && !findings.length) findings.push(issue('no_subscriptions', 'blocked'));
|
|
186
|
+
for (const subscription of subscriptions) {
|
|
187
|
+
const result = await inspectSubscription(subscription, resolve, args);
|
|
188
|
+
if (Buffer.byteLength(JSON.stringify({ items: [...items, result], findings }, null, 2), 'utf8') > MAX_OUTPUT) { complete = false; findings.push(issue('rule_output_limit')); break; }
|
|
189
|
+
items.push(result);
|
|
190
|
+
complete &&= result.complete;
|
|
191
|
+
}
|
|
192
|
+
const allFindings = [...findings, ...items.flatMap(item => item.findings)];
|
|
193
|
+
return { complete, delivery: 'needs_admin_confirmation', collection: 'needs_admin_confirmation', outcome: outcome(allFindings), findings, items };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validateCondition(row) {
|
|
197
|
+
if (!['ANY', 'NO_VALUE'].includes(row.predicate) && row.propertyValue === undefined) throw new IntegrationError('ANS predicate requires propertyValue');
|
|
198
|
+
if (['LESS_THAN', 'GREATER_THAN', 'NUMBER_EQUALS'].includes(row.predicate) && (row.propertyValue.trim() === '' || !Number.isFinite(Number(row.propertyValue)))) throw new IntegrationError('ANS numeric predicate requires a finite numeric propertyValue');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function setResource(client, kind, desired, expectedDigest, deadline) {
|
|
202
|
+
const current = await named(client, kind, desired.name, deadline);
|
|
203
|
+
if (current && (!expectedDigest || canonicalDigest(current) !== expectedDigest)) throw new IntegrationError('ANS update requires the fresh matching expected_digest');
|
|
204
|
+
if (!current && expectedDigest !== undefined) throw new IntegrationError('ANS create requires expected_digest to be absent');
|
|
205
|
+
const merged = { ...(current ?? {}), ...desired };
|
|
206
|
+
if (kind === 'condition') validateCondition(merged);
|
|
207
|
+
if (kind === 'subscription') {
|
|
208
|
+
const report = await inspectSubscription(merged, resolver(client, deadline));
|
|
209
|
+
// Disabled/snoozed is an intentional state input, not permission to bypass unsafe scope or unresolved references.
|
|
210
|
+
const blocking = report.findings.filter(row => !['subscription_disabled', 'subscription_snoozed', 'event_scope_unverified'].includes(row.code));
|
|
211
|
+
if (!report.complete || blocking.length) throw new IntegrationError('ANS subscription references or event/node scope are incomplete, unsafe or unverified; inspect native rules before writing');
|
|
212
|
+
}
|
|
213
|
+
const accepted = await client.request(current ? 'PUT' : 'POST', `${ROOT}/${kind}${current ? `/${encodeURIComponent(desired.name)}` : ''}`, {
|
|
214
|
+
json: merged, expect: [200, 201, 202, 204], withStatus: true, responseType: 'none', deadline
|
|
215
|
+
});
|
|
216
|
+
const status = accepted.status;
|
|
217
|
+
const result = { name: desired.name, operation: current ? 'update' : 'create', accepted: true, status, verified: false, delivery: 'needs_admin_confirmation' };
|
|
218
|
+
try {
|
|
219
|
+
const observed = await named(client, kind, desired.name, deadline);
|
|
220
|
+
result.verified = observed !== null && Object.entries(merged).every(([key, value]) => canonicalDigest({ value: observed[key] }) === canonicalDigest({ value }));
|
|
221
|
+
result.outcome = result.verified ? 'succeeded' : 'unverified';
|
|
222
|
+
if (observed) result.configuration = projection(kind, observed);
|
|
223
|
+
} catch (error) { result.outcome = 'unverified'; result.source_result = sourceFailure(error); }
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function registerAnsTools(register, client) {
|
|
228
|
+
const pager = createReader(client);
|
|
229
|
+
for (const [tool, kind] of [['ans_list_subscriptions', 'subscription'], ['ans_list_conditions', 'condition'], ['ans_list_action_metadata', 'action']]) {
|
|
230
|
+
register(tool, `List ANS ${kind} metadata`, 'Read bounded native alert configuration metadata using zero-based ANS paging. Condition values and action properties/recipients are withheld. Cursors are signed and runtime-bound; rule access does not prove notification delivery.',
|
|
231
|
+
{ top: top(200, 100), cursor: cursorSchema }, READ_ONLY_ANNOTATIONS,
|
|
232
|
+
args => pager.page({ path: `/${kind}`, top: args.top, cursor: args.cursor, fallback: false, consistency: 'best_effort', identity: 'name', project: row => projection(kind, row), deadline: Date.now() + 30000 }));
|
|
233
|
+
}
|
|
234
|
+
register('ans_validate_native_rules', 'Validate native ANS rules', 'Resolve subscriptions, conditions and action/fallback references completely. Exact cTMS event and node scope is checked conservatively; unsupported combinations require review. Collection and notification delivery always need administrator confirmation.', ruleInputs, READ_ONLY_ANNOTATIONS,
|
|
235
|
+
args => validateNativeRules(client, args));
|
|
236
|
+
register('ans_set_condition', 'Set ANS condition', 'Create or update one condition. Exact confirmation is checked before requests. Updates require fresh canonical configuration_digest as expected_digest; creates must omit it. Metadata is preserved and values are withheld from read-back.',
|
|
237
|
+
{ condition: conditionInput, expected_digest: digest, confirmation: z.string() }, OVERWRITE_ANNOTATIONS, args => {
|
|
238
|
+
requireConfirmation(args.confirmation, `SET ALERT CONDITION ${args.condition.name}`);
|
|
239
|
+
validateCondition(args.condition);
|
|
240
|
+
return setResource(client, 'condition', args.condition, args.expected_digest, Date.now() + 30000);
|
|
241
|
+
});
|
|
242
|
+
register('ans_set_subscription', 'Set ANS subscription', 'Create or update one deliberately scoped subscription, after resolving every condition/action/fallback. Exact confirmation precedes requests. Updates require expected_digest; creates omit it. No action properties or implicit all-events subscriptions are written.',
|
|
243
|
+
{ name, state: z.enum(['ENABLED', 'DISABLED']), conditions: z.array(name).min(1).max(50), actions: z.array(name).min(1).max(50), expected_digest: digest, confirmation: z.string() }, OVERWRITE_ANNOTATIONS, args => {
|
|
244
|
+
requireConfirmation(args.confirmation, `SET ALERT SUBSCRIPTION ${args.name}`);
|
|
245
|
+
const { expected_digest, confirmation, ...desired } = args;
|
|
246
|
+
return setResource(client, 'subscription', desired, expected_digest, Date.now() + 30000);
|
|
247
|
+
});
|
|
248
|
+
}
|