@knowttl/technitium-axi 0.0.0-stage → 0.1.1

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/dist/writes.js ADDED
@@ -0,0 +1,940 @@
1
+ import { createHash, createHmac, randomUUID } from 'node:crypto';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { isIP } from 'node:net';
5
+ import { toASCII } from 'tr46';
6
+ import { readOnly, storeSession, removeSession } from './credentials.js';
7
+ import { ToolError } from './errors.js';
8
+ import { PrivateFiles } from './private-file.js';
9
+ import { checkStatus, Redactor, vendorMessage, vendorRead, vendorRequest } from './vendor.js';
10
+ import { recordFamilies, recordTypeNames } from './write-contracts.js';
11
+ import { settingsFields, dhcpFields, identityFields, validateIdentity, validateConfiguration, configurationForm, settingValue, dhcpValue, validateDhcp } from './configuration.js';
12
+ import { validateUploadArchive } from './archive-contract.js';
13
+ import { clusterState, clusterMatches, clusterPreview, validateCluster, clusterTimers } from './cluster.js';
14
+ import { validateNode, routing } from './routing.js';
15
+ const files = new PrivateFiles();
16
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
17
+ function usage(message) { throw new ToolError('USAGE', message, 'Use --input-file with the typed input contract shown in inventory'); }
18
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
19
+ function canonical(value) {
20
+ if (Array.isArray(value))
21
+ return `[${value.map(canonical).join(',')}]`;
22
+ if (object(value))
23
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
24
+ return JSON.stringify(value);
25
+ }
26
+ const booleans = new Set('overwrite overwriteZone overwriteSoaSerial disabled disable ptr createPtrZone updateSvcbHints autoIpv4Hint autoIpv6Hint dnssecValidation useSerialDateScheme useSoaSerialDateScheme validateZone initializeForwarder overrideCatalogQueryAccess overrideCatalogZoneTransfer overrideCatalogNotify import dnssec ldapIgnoreSslErrors splitText newSplitText'.split(' '));
27
+ const integers = new Set('ttl expiryTtl preference newPreference priority newPriority weight newWeight port newPort naptrOrder naptrPreference naptrNewOrder naptrNewPreference keyTag newKeyTag digestType newDigestType sshfpAlgorithm newSshfpAlgorithm sshfpFingerprintType newSshfpFingerprintType tlsaCertificateUsage newTlsaCertificateUsage tlsaSelector newTlsaSelector tlsaMatchingType newTlsaMatchingType svcPriority newSvcPriority uriPriority newUriPriority uriWeight newUriWeight flags newFlags serial refresh retry expire minimum proxyPort forwarderPriority ldapPort kskKeySize zskKeySize keySize dnsKeyTtl zskRolloverDays rolloverDays iterations saltLength'.split(' '));
28
+ const lists = new Set('zones primaryNameServerAddresses queryAccessNetworkACL zoneTransferNetworkACL zoneTransferTsigKeyNames notifyNameServers notifySecondaryCatalogsNameServers updateNetworkACL blockListUrls forwarders proxyBypass members glue'.split(' '));
29
+ const literalName = (value) => value.replace(/^\.+|\.+$/g, '').toLowerCase();
30
+ function domainName(value) {
31
+ const name = literalName(value);
32
+ if (!/[^\x00-\x7f]/.test(name))
33
+ return name;
34
+ const ascii = toASCII(name, { checkBidi: true, checkJoiners: true });
35
+ if (!ascii)
36
+ usage('Invalid internationalized zone name');
37
+ return ascii;
38
+ }
39
+ function permissionRows(key, value) {
40
+ const name = key === 'userPermissions' ? 'username' : 'name';
41
+ if (!Array.isArray(value))
42
+ usage('Permissions require an array of named permission rows');
43
+ const names = new Set();
44
+ for (const row of value) {
45
+ if (!object(row) || Object.keys(row).sort().join(',') !== ['canDelete', 'canModify', 'canView', name].sort().join(',') ||
46
+ typeof row[name] !== 'string' || !row[name] || /[|\x00-\x1f\x7f]/.test(row[name]) ||
47
+ ['canView', 'canModify', 'canDelete'].some(flag => typeof row[flag] !== 'boolean'))
48
+ usage('Invalid permission row');
49
+ const normalized = row[name].toLowerCase();
50
+ if (names.has(normalized))
51
+ usage('Duplicate permission principal');
52
+ names.add(normalized);
53
+ }
54
+ return value;
55
+ }
56
+ function address(value) {
57
+ if (isIP(value) === 6)
58
+ return new URL(`http://[${value}]/`).hostname.slice(1, -1);
59
+ return value;
60
+ }
61
+ function network(value) {
62
+ const denied = value.startsWith('!');
63
+ const [ip, suffix] = (denied ? value.slice(1) : value).split('/');
64
+ const version = isIP(ip);
65
+ if (!version)
66
+ return value;
67
+ const bits = version === 4 ? 32 : 128;
68
+ const prefix = suffix === undefined ? bits : Number(suffix);
69
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits)
70
+ return value;
71
+ let words;
72
+ if (version === 4)
73
+ words = ip.split('.');
74
+ else {
75
+ const [left, right] = address(ip).split('::');
76
+ const start = left ? left.split(':') : [];
77
+ const end = right ? right.split(':') : [];
78
+ words = right === undefined ? start : [...start, ...Array(8 - start.length - end.length).fill('0'), ...end];
79
+ }
80
+ const width = version === 4 ? 8n : 16n;
81
+ const numeric = words.reduce((result, word) => (result << width) + BigInt(parseInt(word, version === 4 ? 10 : 16)), 0n);
82
+ const shift = BigInt(bits - prefix);
83
+ const masked = (numeric >> shift) << shift;
84
+ const result = Array.from({ length: version === 4 ? 4 : 8 }, (_, index) => Number((masked >> (BigInt((version === 4 ? 3 : 7) - index) * width)) & ((1n << width) - 1n)).toString(version === 4 ? 10 : 16)).join(version === 4 ? '.' : ':');
85
+ return `${denied ? '!' : ''}${address(result)}/${prefix}`;
86
+ }
87
+ function settingsAddress(value) {
88
+ const denied = value.startsWith('!');
89
+ const [literal, suffix] = (denied ? value.slice(1) : value).split('/');
90
+ const [ip, scope] = literal.replace(/^\[|\]$/g, '').split('%');
91
+ const version = isIP(ip);
92
+ if (!version)
93
+ return value;
94
+ const bits = version === 4 ? 32 : 128;
95
+ const prefix = suffix === undefined ? bits : Number(suffix);
96
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits)
97
+ return value;
98
+ let words;
99
+ if (version === 4)
100
+ words = ip.split('.');
101
+ else {
102
+ const hex = ip.replace(/\d+\.\d+\.\d+\.\d+$/, tail => {
103
+ const octets = tail.split('.').map(Number);
104
+ return `${(octets[0] * 256 + octets[1]).toString(16)}:${(octets[2] * 256 + octets[3]).toString(16)}`;
105
+ });
106
+ const [left, right] = hex.split('::');
107
+ const start = left ? left.split(':') : [];
108
+ const end = right ? right.split(':') : [];
109
+ words = right === undefined ? start : [...start, ...Array(8 - start.length - end.length).fill('0'), ...end];
110
+ }
111
+ const width = version === 4 ? 8n : 16n;
112
+ const numeric = words.reduce((result, word) => (result << width) + BigInt(parseInt(word, version === 4 ? 10 : 16)), 0n);
113
+ const shift = BigInt(bits - prefix);
114
+ const masked = (numeric >> shift) << shift;
115
+ const result = Array.from({ length: version === 4 ? 4 : 8 }, (_, index) => Number((masked >> (BigInt((version === 4 ? 3 : 7) - index) * width)) & ((1n << width) - 1n)).toString(version === 4 ? 10 : 16)).join(version === 4 ? '.' : ':');
116
+ return `${denied ? '!' : ''}${result}${scope === undefined ? '' : `%${scope}`}${prefix === bits ? '' : `/${prefix}`}`;
117
+ }
118
+ function baselineValue(key, value) {
119
+ if (['userPermissions', 'groupPermissions'].includes(key)) {
120
+ const name = key === 'userPermissions' ? 'username' : 'name';
121
+ return permissionRows(key, value).map(row => ({ ...row, [name]: row[name].toLowerCase() }))
122
+ .sort((a, b) => String(a[name]).localeCompare(String(b[name])));
123
+ }
124
+ if (['members', 'zoneTransferTsigKeyNames'].includes(key) && Array.isArray(value)) {
125
+ return [...new Set(value.map(item => key === 'members' ? String(item).toLowerCase() : literalName(String(item))))].sort();
126
+ }
127
+ if (['notifyNameServers', 'notifySecondaryCatalogsNameServers', 'queryAccessNetworkACL', 'zoneTransferNetworkACL', 'updateNetworkACL'].includes(key) && Array.isArray(value)) {
128
+ return value.map(item => key.endsWith('NetworkACL') ? network(String(item)) : address(String(item)));
129
+ }
130
+ if (['blockListUrls', 'forwarders'].includes(key) && value === null)
131
+ return [];
132
+ if (key === 'dnsServerDomain' && typeof value === 'string')
133
+ return value.replace(/\.+$/, '').toLowerCase();
134
+ if (Array.isArray(value) && value.length === 0) {
135
+ if (key === 'dnsServerLocalEndPoints')
136
+ value = ['0.0.0.0:53', '[::]:53'];
137
+ if (key === 'webServiceLocalAddresses')
138
+ value = ['0.0.0.0', '[::]'];
139
+ }
140
+ if (['webServiceRealIpHeader', 'dnsOverHttpRealIpHeader'].includes(key) && value === '')
141
+ return 'X-Real-IP';
142
+ if (['eDnsClientSubnetIpv4Override', 'eDnsClientSubnetIpv6Override'].includes(key) && typeof value === 'string' && value !== '')
143
+ return settingsAddress(value);
144
+ if (['dnsServerIPv4SourceAddresses', 'dnsServerIPv6SourceAddresses', 'zoneTransferAllowedNetworks', 'notifyAllowedNetworks', 'qpmLimitBypassList', 'webServiceReverseProxyAddresses', 'dnsReverseProxyNetworkACL', 'reverseProxyNetworkACL', 'recursionNetworkACL', 'blockingBypassList'].includes(key) && Array.isArray(value))
145
+ return value.map(item => settingsAddress(String(item)));
146
+ if (key === 'customBlockingAddresses' && Array.isArray(value)) {
147
+ const addresses = value.map(item => settingsAddress(String(item)));
148
+ return [...addresses.filter(item => isIP(item) === 4), ...addresses.filter(item => isIP(item) === 6)];
149
+ }
150
+ if (key === 'webServiceLocalAddresses' && Array.isArray(value))
151
+ return value.map(item => {
152
+ const ip = settingsAddress(String(item));
153
+ return isIP(ip) === 6 ? `[${ip}]` : ip;
154
+ });
155
+ if (key === 'dnsServerLocalEndPoints' && Array.isArray(value))
156
+ return value.map(item => {
157
+ const endpoint = String(item);
158
+ const parts = /^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/.exec(endpoint);
159
+ const ip = settingsAddress(parts?.[1] ?? endpoint);
160
+ return `${isIP(ip) === 6 ? `[${ip}]` : ip}:${Number(parts?.[2]) || 53}`;
161
+ });
162
+ if (key === 'webServiceCspFrameAncestorsHeader' && value === '')
163
+ return "'none'";
164
+ if (key === 'forwarderProtocol' && value === 'HttpsJson')
165
+ return 'Https';
166
+ if (settingsFields[key]?.type === 'string' && value === null)
167
+ return '';
168
+ if (['webServiceTlsCertificatePassword', 'dnsTlsCertificatePassword'].includes(key) && typeof value === 'string')
169
+ return value === '' ? '' : '************';
170
+ if (settingsFields[key] && ['list', 'ports', 'rows'].includes(settingsFields[key].type) && value === null)
171
+ return [];
172
+ if (key === 'tsigKeys' && Array.isArray(value))
173
+ return value.map(row => ({ ...row, keyName: domainName(row.keyName), algorithmName: row.algorithmName.toLowerCase().replace(/\.$/, '') })).sort((a, b) => a.keyName.localeCompare(b.keyName));
174
+ if (['catalog', 'primaryZoneTransferTsigKeyName'].includes(key) && value === null)
175
+ return '';
176
+ if (['catalog', 'primaryZoneTransferTsigKeyName'].includes(key) && typeof value === 'string')
177
+ return literalName(value);
178
+ if (['primaryZoneTransferProtocol', 'queryAccess', 'zoneTransfer', 'notify', 'update'].includes(key) && typeof value === 'string')
179
+ return value.toLowerCase();
180
+ if (key === 'updateSecurityPolicies') {
181
+ let policies;
182
+ if (Array.isArray(value)) {
183
+ policies = value.flatMap(row => {
184
+ if (!object(row) || typeof row.tsigKeyName !== 'string' || typeof row.domain !== 'string' || !Array.isArray(row.allowedTypes) || row.allowedTypes.some(type => typeof type !== 'string'))
185
+ usage('Invalid update security policy baseline');
186
+ return [row.tsigKeyName, row.domain, row.allowedTypes.join(',')];
187
+ });
188
+ }
189
+ if (typeof value === 'string')
190
+ policies = value === '' || value.toLowerCase() === 'false' ? [] : value.split('|').filter(Boolean);
191
+ if (policies) {
192
+ if (policies.length % 3 !== 0)
193
+ usage('Update security policies require key, domain and allowed types');
194
+ const rows = new Map();
195
+ for (let i = 0; i < policies.length; i += 3) {
196
+ const key = `${literalName(policies[i])}|${literalName(policies[i + 1])}`;
197
+ rows.set(key, [...rows.get(key) ?? [], ...policies[i + 2].split(/[, ]+/).filter(Boolean).map(type => type.toUpperCase())]);
198
+ }
199
+ return [...rows].map(([key, types]) => `${key}|${types.sort().join(',')}`).sort().join('|');
200
+ }
201
+ }
202
+ return value;
203
+ }
204
+ function selectedValues(values, dhcp = false) {
205
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, dhcp ? dhcpValue(key, value) : baselineValue(key, value)]));
206
+ }
207
+ function validateValue(key, value) {
208
+ if (['forceDelete', 'forceLeave', 'forceDeletePrimary', 'ignoreCertificateErrors'].includes(key)) {
209
+ if (typeof value !== 'boolean')
210
+ usage('Boolean input requires true or false');
211
+ return;
212
+ }
213
+ if (clusterTimers.includes(key) || key.endsWith('NodeId') || key.endsWith('IpAddresses') || key === 'ipAddresses') {
214
+ validateCluster({ [key]: value });
215
+ return;
216
+ }
217
+ if (['allowedZones', 'blockedZones'].includes(key)) {
218
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || !item.length || /[\x00-\x20\x7f,]/.test(item)))
219
+ usage('Zone import requires an array of domain names without separators');
220
+ return;
221
+ }
222
+ if (['userPermissions', 'groupPermissions'].includes(key)) {
223
+ permissionRows(key, value);
224
+ }
225
+ else if (booleans.has(key)) {
226
+ if (typeof value !== 'boolean')
227
+ usage('Boolean input requires true or false');
228
+ }
229
+ else if (integers.has(key)) {
230
+ if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > 4294967295)
231
+ usage(`${key} must be an integer from 0 to 4294967295`);
232
+ }
233
+ else if (lists.has(key)) {
234
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || /[\x00-\x1f\x7f,]/.test(item)))
235
+ usage('List input requires an array of strings without separators');
236
+ }
237
+ else if ((key === 'algorithm' || key === 'newAlgorithm') && typeof value === 'number') {
238
+ if (!Number.isSafeInteger(value) || value < 0 || value > 255)
239
+ usage('Invalid DNSSEC algorithm');
240
+ }
241
+ else if (typeof value !== 'string' || value.length > 1024 * 1024 || /[\x00\r]/.test(value))
242
+ usage('String input has an invalid type or size');
243
+ if (value === '[REDACTED]')
244
+ usage('Redacted output cannot be used as an update value');
245
+ }
246
+ function validateRecord(contract, parameters) {
247
+ const suppliedType = parameters.type;
248
+ if (typeof suppliedType !== 'string')
249
+ usage('Record type must be a name or decimal number');
250
+ const numeric = /^\d+$/.test(suppliedType) && Number(suppliedType) > 0 && Number(suppliedType) <= 65535;
251
+ const knownType = numeric ? recordTypeNames[Number(suppliedType)] : undefined;
252
+ const type = knownType ?? suppliedType;
253
+ parameters.type = type;
254
+ const family = recordFamilies[type] ?? (numeric && !knownType ? recordFamilies.raw : undefined);
255
+ if (!family)
256
+ usage('Unsupported record type; generated DNSSEC records use lifecycle operations');
257
+ const action = contract.id;
258
+ if (type === 'SOA' && action !== 'OP-051')
259
+ usage('SOA records use apex update or zone lifecycle operations');
260
+ if (type === 'SOA' && parameters.domain !== parameters.zone)
261
+ usage('SOA must select the zone apex');
262
+ const metadata = action === 'OP-049' ? ['ttl', 'overwrite', 'comments', 'expiryTtl'] : action === 'OP-051' ? ['ttl', 'newDomain', 'disable', 'comments', 'expiryTtl'] : [];
263
+ const optional = action === 'OP-052' ? (['A', 'AAAA'].includes(type) ? ['updateSvcbHints'] : type === 'TXT' ? ['splitText', 'characterStringsBase64'] : [])
264
+ : (family.optional ?? []).filter(key => action !== 'OP-049' || !key.startsWith('new'));
265
+ const fields = new Set(['node', 'zone', 'domain', 'type', ...metadata, ...optional,
266
+ ...(action === 'OP-052' ? family.selector : action === 'OP-049' ? (family.selector.length ? family.selector : family.replacement) : [...family.selector, ...family.replacement])]);
267
+ // Add uses current rData names. Singleton replacement fields already use those names.
268
+ const required = action === 'OP-049' ? (family.selector.length ? family.selector : family.replacement)
269
+ : action === 'OP-051' && !family.selector.length ? family.replacement : family.selector;
270
+ for (const key of Object.keys(parameters))
271
+ if (!fields.has(key))
272
+ usage('Field does not apply to this record type and action');
273
+ for (const key of required) {
274
+ if (type === 'TXT' && key === 'text' && Object.hasOwn(parameters, 'characterStringsBase64'))
275
+ continue;
276
+ if (!Object.hasOwn(parameters, key))
277
+ usage('The record selector or data is incomplete');
278
+ }
279
+ for (const prefix of ['', 'new']) {
280
+ const text = prefix ? 'newText' : 'text';
281
+ const base64 = prefix ? 'newCharacterStringsBase64' : 'characterStringsBase64';
282
+ const split = prefix ? 'newSplitText' : 'splitText';
283
+ if (Object.hasOwn(parameters, base64) && (Object.hasOwn(parameters, text) || Object.hasOwn(parameters, split)))
284
+ usage('Choose TXT text or characterStringsBase64, not both');
285
+ }
286
+ }
287
+ export async function writeInput(contract, path) {
288
+ if (!path)
289
+ usage('A protected --input-file is required');
290
+ let input;
291
+ try {
292
+ input = JSON.parse(await files.read(path));
293
+ }
294
+ catch (error) {
295
+ if (error instanceof SyntaxError)
296
+ usage('Input must be valid JSON');
297
+ throw error;
298
+ }
299
+ if (!object(input) || !object(input.parameters) || Object.keys(input).some(key => !['parameters', 'baseline', 'upload', 'encoding'].includes(key)))
300
+ usage('Input requires parameters and optional baseline or upload');
301
+ if (input.encoding !== undefined && (contract.id !== 'OP-080' || !['form', 'json'].includes(String(input.encoding))))
302
+ usage('Only settings set supports form or json encoding');
303
+ const parameters = input.parameters;
304
+ if (contract.id === 'OP-003' && ((parameters.user === undefined) !== (parameters.pass === undefined) || parameters.totp !== undefined && parameters.pass === undefined))
305
+ usage('Password acquisition requires user and pass together; omit all password fields for the selected bearer');
306
+ if (['OP-008', 'OP-104'].includes(contract.id) && parameters.iterations !== undefined && (!Number.isInteger(parameters.iterations) || Number(parameters.iterations) < 100000 || Number(parameters.iterations) > 1000000))
307
+ usage('Iterations must be between 100000 and 1000000');
308
+ if (['OP-013', 'OP-104'].includes(contract.id) && parameters.sessionTimeoutSeconds !== undefined && (!Number.isInteger(parameters.sessionTimeoutSeconds) || Number(parameters.sessionTimeoutSeconds) < 0 || Number(parameters.sessionTimeoutSeconds) > 604800))
309
+ usage('Session timeout must be between 0 and 604800 seconds');
310
+ if (contract.id === 'OP-076' && (!object(parameters.config) || typeof parameters.config.file !== 'string'))
311
+ usage('App config requires a protected absolute file SecretRef');
312
+ if (contract.id === 'OP-092' && parameters.useThisDnsServer === true && Object.hasOwn(parameters, 'dnsServers'))
313
+ usage('dnsServers is ignored when useThisDnsServer is true; select one DNS server mode');
314
+ if (contract.command.startsWith('dhcp lease ') && ((parameters.clientIdentifier === undefined) === (parameters.hardwareAddress === undefined) ||
315
+ parameters.clientIdentifier === '' || parameters.hardwareAddress === ''))
316
+ usage('Choose exactly one nonempty clientIdentifier or hardwareAddress');
317
+ if (contract.id === 'OP-080')
318
+ for (const values of [parameters, input.baseline]) {
319
+ if (!object(values) || !Object.hasOwn(values, 'defaultResponsiblePerson'))
320
+ continue;
321
+ const value = values.defaultResponsiblePerson;
322
+ if (typeof value !== 'string' || value !== '' && !/^[^\s@()<>\[\]",:;\\]+@[^\s@()<>\[\]",:;\\]+$/.test(value))
323
+ usage('defaultResponsiblePerson requires a plain local@domain address without display names, comments or angle brackets, or an empty string to clear it');
324
+ }
325
+ if (contract.id === 'OP-080')
326
+ for (const [modern, legacy] of [['ipv6Mode', 'preferIPv6'], ['loggingType', 'enableLogging'], ['dnsReverseProxyNetworkACL', 'reverseProxyNetworkACL']]) {
327
+ if (Object.hasOwn(parameters, modern) && Object.hasOwn(parameters, legacy))
328
+ usage('Choose the modern setting or its compatibility alias, not both');
329
+ }
330
+ for (const key of Object.keys(parameters))
331
+ if (!contract.fields.includes(key))
332
+ usage('Unknown input field');
333
+ validateNode(contract.id, parameters.node);
334
+ if (contract.command.startsWith('admin cluster '))
335
+ validateCluster(parameters);
336
+ for (const key of contract.required)
337
+ if (!Object.hasOwn(parameters, key) || parameters[key] === '')
338
+ usage('Required input is missing');
339
+ if (['OP-070', 'OP-071'].includes(contract.id)) {
340
+ let url;
341
+ try {
342
+ url = new URL(String(parameters.url));
343
+ }
344
+ catch {
345
+ usage('App download URL must be an absolute HTTPS URL');
346
+ }
347
+ if (url.protocol !== 'https:')
348
+ usage('App download URL must use HTTPS');
349
+ }
350
+ if (contract.command.startsWith('dhcp ') && parameters.hardwareAddress !== undefined &&
351
+ (typeof parameters.hardwareAddress !== 'string' || !/^(?:[\da-fA-F]{2}[:-]){5}[\da-fA-F]{2}$/.test(parameters.hardwareAddress)))
352
+ usage('hardwareAddress requires six hexadecimal octets separated by colons or hyphens');
353
+ if (contract.id === 'OP-093' && isIP(String(parameters.ipAddress)) !== 4)
354
+ usage('ipAddress requires an IPv4 address');
355
+ if (contract.id === 'OP-136' && (typeof parameters.log !== 'string' || /[\\/:]/.test(parameters.log) || ['.', '..'].includes(parameters.log)))
356
+ usage('Log deletion requires the fileName returned by log list, without a path');
357
+ for (const [key, value] of Object.entries(parameters)) {
358
+ if (contract.secrets?.includes(key)) {
359
+ if (!object(value) || Object.keys(value).length !== 1 || !((typeof value.env === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value.env)) ||
360
+ (typeof value.file === 'string' && isAbsolute(value.file))))
361
+ usage('Secret fields require an environment or protected absolute file SecretRef');
362
+ }
363
+ else {
364
+ if (contract.id === 'OP-080')
365
+ validateConfiguration(settingsFields[key], value);
366
+ else if (contract.id === 'OP-092')
367
+ validateDhcp(key, value);
368
+ else if (['OP-115', 'OP-142', 'OP-143'].includes(contract.id))
369
+ validateIdentity(key, value);
370
+ else if (contract.id === 'OP-083' && key === 'minutes') {
371
+ if (!Number.isSafeInteger(value) || Number(value) <= 0 || Number(value) > 2147483647)
372
+ usage('Minutes must be a positive 32-bit integer');
373
+ }
374
+ else if (contract.id === 'OP-085' && key !== 'node') {
375
+ if (typeof value !== 'boolean')
376
+ usage('Restore selections require true or false');
377
+ }
378
+ else if (key === 'totpEnabled') {
379
+ if (typeof value !== 'boolean')
380
+ usage('A boolean is required');
381
+ }
382
+ else if (key === 'memberOfGroups') {
383
+ if (!Array.isArray(value) || value.some(group => typeof group !== 'string' || !group || /[,|\x00-\x1f]/.test(group)))
384
+ usage('memberOfGroups requires an array of group names');
385
+ }
386
+ else if (key === 'sessionTimeoutSeconds') {
387
+ if (!Number.isSafeInteger(value))
388
+ usage('An integer is required');
389
+ }
390
+ else
391
+ validateValue(key, value);
392
+ if (key === 'updateSecurityPolicies')
393
+ baselineValue(key, value);
394
+ }
395
+ }
396
+ if (['OP-049', 'OP-051', 'OP-052'].includes(contract.id))
397
+ validateRecord(contract, parameters);
398
+ if (contract.command.startsWith('zone dnssec ')) {
399
+ const bounds = { keyTag: 65535, rolloverDays: 365, zskRolloverDays: 365, iterations: 50, saltLength: 32 };
400
+ for (const [key, maximum] of Object.entries(bounds)) {
401
+ if (parameters[key] !== undefined && Number(parameters[key]) > maximum)
402
+ usage(`${key} must be between 0 and ${maximum}`);
403
+ }
404
+ }
405
+ if (['OP-022', 'OP-026'].includes(contract.id) && !['Primary', 'Secondary', 'Stub', 'Forwarder', 'SecondaryForwarder', 'Catalog', 'SecondaryCatalog'].includes(String(parameters.type)))
406
+ usage('Unsupported zone type');
407
+ if (contract.id === 'OP-022' && ['SecondaryForwarder', 'SecondaryCatalog'].includes(String(parameters.type)) && (!Array.isArray(parameters.primaryNameServerAddresses) || !parameters.primaryNameServerAddresses.length))
408
+ usage('Secondary forwarder and catalog creation require primary name server addresses');
409
+ if (contract.id === 'OP-029' && ((parameters.zone === undefined) === (parameters.zones === undefined) || parameters.zone === '' || (Array.isArray(parameters.zones) && parameters.zones.length === 0)))
410
+ usage('Choose a zone or a nonempty zones list');
411
+ if (input.baseline !== undefined && !object(input.baseline))
412
+ usage('Baseline must be an object');
413
+ // Without a baseline, a DHCP scope set creates the named scope; the vendor setter creates it when it does not exist.
414
+ if (contract.id === 'OP-092' && input.baseline === undefined) {
415
+ if (['startingAddress', 'endingAddress', 'subnetMask'].some(key => !Object.hasOwn(parameters, key)) || Object.hasOwn(parameters, 'newName'))
416
+ usage('Creating a scope requires startingAddress, endingAddress and subnetMask and no newName; to modify an existing scope supply its exact selected baseline');
417
+ }
418
+ else if (contract.baselinePath) {
419
+ const fields = Object.keys(parameters).filter(key => !contract.baselineTarget?.includes(key));
420
+ if (!fields.length || !object(input.baseline) || canonical(Object.keys(input.baseline).sort()) !== canonical(fields.sort()))
421
+ usage('Provide the exact selected baseline for every changed field');
422
+ for (const [key, value] of Object.entries(input.baseline)) {
423
+ if (contract.id === 'OP-080')
424
+ validateConfiguration(settingsFields[key], baselineValue(key, value));
425
+ else if (contract.id === 'OP-092')
426
+ validateDhcp(key, dhcpValue(key, value));
427
+ else
428
+ validateValue(key, baselineValue(key, value));
429
+ }
430
+ }
431
+ else if (input.baseline !== undefined)
432
+ usage('Baseline is not supported by this operation');
433
+ if (input.upload !== undefined) {
434
+ if (!['OP-022', 'OP-023', 'OP-072', 'OP-073', 'OP-085'].includes(contract.id) || !object(input.upload) || Object.keys(input.upload).some(key => !['file', 'format'].includes(key)) || typeof input.upload.file !== 'string' || !isAbsolute(input.upload.file))
435
+ usage('Upload requires a protected absolute file for a supported upload operation');
436
+ if (input.upload.format !== undefined && !['multipart', 'text'].includes(String(input.upload.format)))
437
+ usage('Upload format must be multipart or text');
438
+ if (!['OP-022', 'OP-023'].includes(contract.id) && input.upload.format !== undefined)
439
+ usage('Package uploads require multipart transport');
440
+ if (Object.hasOwn(parameters, 'records') || (contract.id === 'OP-022' && (!['Primary', 'Forwarder'].includes(String(parameters.type)) || input.upload.format === 'text')))
441
+ usage('Uploaded records require Primary or Forwarder multipart creation, or an import without inline records');
442
+ }
443
+ if (contract.id === 'OP-023' && input.upload === undefined && typeof parameters.records !== 'string')
444
+ usage('Import requires records or an upload');
445
+ if (['OP-072', 'OP-073', 'OP-085'].includes(contract.id) && !input.upload)
446
+ usage('A protected package or backup upload is required');
447
+ return input;
448
+ }
449
+ function journalPath(digest) {
450
+ const base = process.platform === 'win32' ? process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local')
451
+ : process.env.XDG_STATE_HOME && isAbsolute(process.env.XDG_STATE_HOME) ? process.env.XDG_STATE_HOME : join(homedir(), '.local', 'state');
452
+ const path = process.env.TECHNITIUM_AXI_WRITE_LOG ?? join(base, 'technitium-axi', 'writes.log');
453
+ if (!isAbsolute(path))
454
+ usage('TECHNITIUM_AXI_WRITE_LOG must be absolute');
455
+ return join(`${path}.d`, `${digest}.json`);
456
+ }
457
+ async function journalEntry(path) {
458
+ try {
459
+ return JSON.parse(await files.read(path));
460
+ }
461
+ catch (error) {
462
+ if (error.code === 'ENOENT')
463
+ throw new ToolError('CONFIRMATION_MISMATCH', 'No executed operation matches the supplied confirmation', 'Supply the exact digest from the original preview and execution', 1);
464
+ throw error;
465
+ }
466
+ }
467
+ async function baselineRead(contract, profile, token, parameters, fields) {
468
+ const target = new URLSearchParams();
469
+ for (const key of contract.baselineTarget ?? [])
470
+ if (parameters[key] !== undefined)
471
+ target.set(key, String(parameters[key]));
472
+ const result = await vendorRequest(profile, contract.baselinePath, target.toString(), token, {}, { bytes: 16 * 1024 * 1024, elapsedMs: 10000 });
473
+ if (result.status !== 200 || result.contentType !== 'application/json')
474
+ throw new ToolError('UPSTREAM_INVALID', 'Baseline read failed', 'Repeat the selected target read', 1);
475
+ const value = JSON.parse(result.bytes.toString());
476
+ checkStatus(value);
477
+ if (!object(value.response))
478
+ throw new ToolError('UPSTREAM_INVALID', 'Selected baseline fields are missing', 'Check the selected target and API version', 1);
479
+ const response = value.response;
480
+ const preserved = {};
481
+ if (contract.id === 'OP-080')
482
+ for (const prefix of ['webServiceTlsCertificate', 'dnsTlsCertificate']) {
483
+ if (fields.includes(`${prefix}Password`) && !fields.includes(`${prefix}Path`)) {
484
+ const path = response[`${prefix}Path`];
485
+ if (typeof path !== 'string' || !path)
486
+ usage('A certificate password update requires an existing or selected certificate path');
487
+ preserved[`${prefix}Path`] = path;
488
+ }
489
+ }
490
+ if (contract.id === 'OP-080' && fields.includes('forwarders') && !fields.includes('forwarderProtocol')) {
491
+ validateValue('forwarderProtocol', response.forwarderProtocol);
492
+ preserved.forwarderProtocol = response.forwarderProtocol;
493
+ }
494
+ if (contract.id === 'OP-080' && fields.includes('forwarderProtocol') && !fields.includes('forwarders')) {
495
+ preserved.forwarders = baselineValue('forwarders', response.forwarders);
496
+ validateConfiguration(settingsFields.forwarders, preserved.forwarders, true);
497
+ }
498
+ if (contract.id === 'OP-080' && fields.some(key => key.startsWith('proxy')) && parameters.proxyType !== 'None') {
499
+ for (const target of ['proxyType', 'proxyAddress', 'proxyPort', 'proxyUsername', 'proxyPassword', 'proxyBypass']) {
500
+ if (fields.includes(target))
501
+ continue;
502
+ const item = settingValue(response, target);
503
+ validateValue(target, item);
504
+ preserved[target] = item;
505
+ }
506
+ }
507
+ const selected = Object.fromEntries(fields.map(key => {
508
+ if (contract.command.startsWith('dhcp ')) {
509
+ if (key === 'reservedLeases' && !Array.isArray(response[key]))
510
+ throw new ToolError('UPSTREAM_INVALID', 'Scope reservations are missing', 'Read the selected scope and check the server API version', 1);
511
+ const item = dhcpValue(key, response[key === 'newName' ? 'name' : key]);
512
+ validateDhcp(key, item);
513
+ return [key, item];
514
+ }
515
+ if (contract.id === 'OP-080') {
516
+ const item = baselineValue(key, settingValue(response, key));
517
+ validateConfiguration(settingsFields[key], item, true);
518
+ return [key, item];
519
+ }
520
+ const source = contract.id === 'OP-109' && key === 'newGroup' ? 'name' : key;
521
+ if (!Object.hasOwn(response, source))
522
+ throw new ToolError('UPSTREAM_INVALID', 'Selected baseline fields are missing', 'Check the selected target and API version', 1);
523
+ const item = baselineValue(key, response[source]);
524
+ validateValue(key, item);
525
+ return [key, item];
526
+ }));
527
+ const unchanged = {};
528
+ if (contract.id === 'OP-080') {
529
+ const changed = new Set(fields);
530
+ if (changed.has('preferIPv6'))
531
+ changed.add('ipv6Mode');
532
+ if (changed.has('enableLogging'))
533
+ changed.add('loggingType');
534
+ if (changed.has('reverseProxyNetworkACL'))
535
+ changed.add('dnsReverseProxyNetworkACL');
536
+ if (changed.has('proxyType') && parameters.proxyType === 'None')
537
+ for (const key of Object.keys(settingsFields).filter(key => key.startsWith('proxy')))
538
+ changed.add(key);
539
+ if (changed.has('forwarders'))
540
+ changed.add('forwarderProtocol');
541
+ for (const prefix of ['webServiceTlsCertificate', 'dnsTlsCertificate'])
542
+ if (changed.has(`${prefix}Path`))
543
+ changed.add(`${prefix}Password`);
544
+ for (const key of Object.keys(settingsFields)) {
545
+ if (changed.has(key) || ['preferIPv6', 'enableLogging', 'reverseProxyNetworkACL'].includes(key))
546
+ continue;
547
+ const item = settingValue(response, key);
548
+ if (item !== undefined)
549
+ unchanged[key] = baselineValue(key, item);
550
+ }
551
+ }
552
+ return { selected, preserved, ...(contract.id === 'OP-080' ? { unchanged } : {}) };
553
+ }
554
+ async function soaSerial(profile, token, parameters) {
555
+ const form = new URLSearchParams({ domain: String(parameters.zone), zone: String(parameters.zone), listZone: 'true' });
556
+ if (parameters.node !== undefined)
557
+ form.set('node', String(parameters.node));
558
+ const result = await vendorRequest(profile, '/api/zones/records/get', form.toString(), token, {}, { bytes: 16 * 1024 * 1024, elapsedMs: 10000 });
559
+ if (result.status !== 200 || result.contentType !== 'application/json')
560
+ throw new ToolError('UPSTREAM_INVALID', 'SOA read failed', 'Read the selected secondary zone', 1);
561
+ const value = JSON.parse(result.bytes.toString());
562
+ checkStatus(value);
563
+ if (!object(value.response) || !Array.isArray(value.response.records))
564
+ usage('SOA read requires complete zone records');
565
+ const soa = value.response.records.find(record => object(record) && record.type === 'SOA' && domainName(String(record.name)) === domainName(String(parameters.zone)));
566
+ if (!object(soa) || !object(soa.rData) || !Number.isSafeInteger(soa.rData.serial))
567
+ usage('Selected zone SOA serial is missing');
568
+ return Number(soa.rData.serial);
569
+ }
570
+ export async function writeOperation(contract, input, profile, identity, token, options, redactor) {
571
+ const capability = contract.id === 'OP-077' ? input.parameters.import === true ? 'OP-077.import' : 'OP-077.resolve' : contract.id;
572
+ const mutating = capability !== 'OP-077.resolve';
573
+ const parameters = { ...input.parameters };
574
+ const cluster = contract.command.startsWith('admin cluster ');
575
+ if (cluster && options.reconcile && (!options.confirm || !/^[a-f0-9-]{36}\.[a-f0-9]{64}$/.test(options.confirm)))
576
+ usage('Reconciliation requires the exact preview confirmation');
577
+ if (cluster && profile.tokenKind === 'single-use')
578
+ throw new ToolError('TOKEN_KIND', 'Cluster verification requires multiple authenticated requests', 'Select an API or session profile', 1);
579
+ const clusterBefore = cluster ? options.reconcile && options.confirm
580
+ ? (await journalEntry(journalPath(options.confirm))).clusterBefore
581
+ : await clusterState(profile, token, parameters.node) : undefined;
582
+ if (contract.id === 'OP-126' && clusterBefore && Number(parameters.configRefreshIntervalSeconds ?? clusterBefore.configRefreshIntervalSeconds) <= Number(parameters.heartbeatRefreshIntervalSeconds ?? clusterBefore.heartbeatRefreshIntervalSeconds))
583
+ usage('Config refresh interval must exceed heartbeat refresh interval');
584
+ const acquisition = contract.id === 'OP-002' || contract.id === 'OP-003' && parameters.pass !== undefined;
585
+ if (['OP-003', 'OP-004', 'OP-008', 'OP-009', 'OP-010', 'OP-011', 'OP-013'].includes(contract.id) && !acquisition && profile.tokenKind !== 'session')
586
+ throw new ToolError('TOKEN_KIND', 'A Standard session is required', 'Explicitly sign in with a session profile', 1);
587
+ if (contract.id === 'OP-002' && (profile.tokenKind !== 'session' || !profile.username || profile.username !== parameters.user))
588
+ usage('Login requires a session profile whose username matches user');
589
+ if (contract.id === 'OP-002')
590
+ parameters.includeInfo = true;
591
+ let route;
592
+ if (['OP-116', 'OP-117'].includes(contract.id)) {
593
+ if (profile.tokenKind === 'single-use')
594
+ throw new ToolError('TOKEN_KIND', 'SSO preflight needs multiple requests', 'Use an API or session profile', 1);
595
+ const session = await identityResponse(profile, '/api/user/session/get', 'includeInfo=true', token);
596
+ if (typeof session.token === 'string')
597
+ redactor.add(session.token);
598
+ if (!object(session.info) || !object(session.info.permissions) || !object(session.info.permissions.Administration) || session.info.permissions.Administration.canModify !== true || typeof session.username !== 'string')
599
+ throw new ToolError('PERMISSION_DENIED', 'The actual caller requires Administration Modify', 'Use a caller with Administration Modify on the selected secondary', 1);
600
+ const cluster = session.info;
601
+ const nodes = object(cluster) && Array.isArray(cluster.clusterNodes) ? cluster.clusterNodes.filter(object) : [];
602
+ const secondary = nodes.find(node => node.state === 'Self' && node.type === 'Secondary');
603
+ const primary = nodes.find(node => node.type === 'Primary');
604
+ if (!object(cluster) || cluster.clusterInitialized !== true || !secondary || !primary || typeof primary.url !== 'string')
605
+ throw new ToolError('PREREQUISITE_REQUIRED', 'SSO writes require an initialized secondary-to-primary route', 'Select an initialized secondary profile; the vendor proxy supplies the cluster identity', 1);
606
+ route = { requested: profile.origin, effective: primary.url, caller: session.username };
607
+ }
608
+ const baselineInput = input.baseline ? { ...input.baseline } : undefined;
609
+ const creatingScope = contract.id === 'OP-092' && !input.baseline;
610
+ const baselineFields = input.baseline ? Object.keys(input.baseline) : creatingScope ? Object.keys(parameters).filter(key => key !== 'name') : [];
611
+ const pointer = ['OP-049', 'OP-051'].includes(contract.id) && parameters.ptr === true;
612
+ // The vendor performs these steps implicitly, so the preview must name them.
613
+ const sideEffects = [
614
+ ...(pointer ? ['Adds or updates the matching PTR record for the address'] : []),
615
+ ...(pointer && parameters.createPtrZone === true ? ['Creates the reverse zone when it does not exist'] : []),
616
+ ...(creatingScope ? ['Creates a new DHCP scope'] : []),
617
+ ...(['OP-088', 'OP-089', 'OP-093', 'OP-095'].includes(contract.id)
618
+ || contract.id === 'OP-092' && (parameters.dnsUpdates === true || typeof parameters.domainName === 'string' && parameters.domainName !== '' || parameters.reservedLeases !== undefined)
619
+ ? ['May create the forward and reverse DNS zones for the scope domain name when DNS updates are enabled'] : []),
620
+ ...(capability === 'OP-077.import' ? ['May create a primary DNS zone for the imported records when no suitable zone exists'] : []),
621
+ ];
622
+ if (contract.id === 'OP-080')
623
+ for (const values of [parameters, baselineInput]) {
624
+ if (!Array.isArray(values?.tsigKeys))
625
+ continue;
626
+ values.tsigKeys = await Promise.all(values.tsigKeys.map(async (row) => {
627
+ const ref = row.sharedSecret;
628
+ const value = ref.file ? await files.read(ref.file) : process.env[ref.env];
629
+ if (value === undefined || value.length > 1024 * 1024)
630
+ usage('TSIG SecretRef is missing or too large');
631
+ redactor.add(value);
632
+ const resolved = { ...row, sharedSecret: value };
633
+ validateConfiguration(settingsFields.tsigKeys, [resolved], true);
634
+ return resolved;
635
+ }));
636
+ }
637
+ for (const values of [parameters, baselineInput])
638
+ for (const key of contract.secrets ?? []) {
639
+ const ref = values?.[key];
640
+ if (ref === undefined)
641
+ continue;
642
+ const selected = ref;
643
+ const value = selected.file ? await files.read(selected.file) : process.env[selected.env];
644
+ if (value === undefined || value.length > 1024 * 1024)
645
+ usage('SecretRef is missing or too large');
646
+ redactor.add(value);
647
+ if (contract.id === 'OP-080')
648
+ validateConfiguration(settingsFields[key], value, true);
649
+ if (['OP-115', 'OP-142', 'OP-143'].includes(contract.id))
650
+ validateIdentity(key, value, true);
651
+ values[key] = value;
652
+ }
653
+ let upload;
654
+ if (input.upload) {
655
+ upload = await files.readBytes(input.upload.file);
656
+ if (['OP-072', 'OP-073', 'OP-085'].includes(contract.id))
657
+ validateUploadArchive(upload);
658
+ }
659
+ // A keyed digest binds credential and secret revisions without publishing an offline secret verifier.
660
+ const intentId = !options.execute && !options.reconcile ? randomUUID() : options.confirm?.split('.')[0];
661
+ const digest = `${intentId}.${createHmac('sha256', token).update(canonical({ intentId, origin: profile.origin, identity, username: profile.username ?? null,
662
+ tokenKind: profile.tokenKind, caFile: profile.caFile ?? null, tlsServerName: profile.tlsServerName ?? null,
663
+ capability, parameters, route: route ?? null, ...(clusterBefore ? { clusterBefore } : {}), baseline: baselineInput ?? null, encoding: input.encoding ?? 'form', upload: upload ? { sha256: hash(upload), format: input.upload?.format ?? 'multipart' } : null })).digest('hex')}`;
664
+ const permitted = !mutating || (!readOnly() && profile.writes?.allowWrites === true && profile.writes.operations.includes(capability));
665
+ if (!options.execute && !options.reconcile)
666
+ return { bytes: Buffer.alloc(0), projection: { preview: { digest, operation: capability,
667
+ origin: profile.origin, identity, node: parameters.node ?? null, target: parameters.secondaryNodeId ?? parameters.primaryNodeId ?? parameters.clusterDomain ?? parameters.zone ?? parameters.zones ?? parameters.domain ?? parameters.group ?? parameters.user ?? parameters.partialToken ?? parameters.section ?? parameters.log ?? parameters.name ?? null,
668
+ permitted, upload: upload ? { bytes: upload.length, sha256: hash(upload) } : null,
669
+ ...(route ? { route } : {}),
670
+ routing: routing(contract.id),
671
+ ...(clusterBefore ? { cluster: clusterPreview(contract.id, parameters, clusterBefore) } : {}),
672
+ replaces: input.baseline ? baselineFields : [],
673
+ ...(sideEffects.length ? { sideEffects } : {}),
674
+ ...(contract.baselinePath ? { concurrency: 'A single-writer window is required between the final read and write; the vendor has no atomic conditional setter' } : {}) } } };
675
+ if (!permitted)
676
+ throw new ToolError('WRITE_DENIED', 'Exact write grant is required and read-only mode overrides grants', 'Enable only the selected operation in the selected profile', 1);
677
+ if (!options.confirm)
678
+ usage('Missing --confirm; preview the operation and pass its digest');
679
+ if (!intentId || !/^[a-f0-9-]{36}$/.test(intentId) || options.confirm !== digest)
680
+ throw new ToolError('CONFIRMATION_MISMATCH', 'Preview confirmation no longer matches the selected input', 'Preview again and supply its exact digest', 1);
681
+ if ((contract.baselinePath || ['OP-030', 'OP-093'].includes(contract.id)) && profile.tokenKind === 'single-use')
682
+ throw new ToolError('TOKEN_KIND', 'This action needs multiple authenticated requests', 'Select an API or session profile for this operation', 1);
683
+ const auditId = randomUUID();
684
+ const path = journalPath(digest);
685
+ const postconditionHash = (value) => createHmac('sha256', token).update(canonical(value)).digest('hex');
686
+ let resyncSerial;
687
+ const postcondition = async (expected) => {
688
+ // Topology cannot prove that an asynchronous notification or resync was scheduled.
689
+ if (options.reconcile && ['OP-129', 'OP-130'].includes(contract.id))
690
+ return false;
691
+ if (clusterBefore)
692
+ return clusterMatches(contract.id, parameters, clusterBefore, options.reconciliation
693
+ ? await clusterState(options.reconciliation.profile, options.reconciliation.token, undefined)
694
+ : await clusterState(profile, token, parameters.node));
695
+ if (contract.id === 'OP-030' && resyncSerial !== undefined) {
696
+ const serial = await soaSerial(profile, token, parameters);
697
+ const distance = (serial - resyncSerial + 4294967296) % 4294967296;
698
+ return distance > 0 && distance < 2147483648;
699
+ }
700
+ if (!contract.baselinePath || !expected)
701
+ return false;
702
+ const target = contract.id === 'OP-109' && parameters.newGroup !== undefined ? { ...parameters, group: parameters.newGroup }
703
+ : contract.id === 'OP-092' && parameters.newName !== undefined ? { ...parameters, name: parameters.newName } : parameters;
704
+ return postconditionHash(await baselineRead(contract, profile, token, target, baselineFields)) === expected;
705
+ };
706
+ if (options.reconcile) {
707
+ const prior = await journalEntry(path);
708
+ if (prior.auditId !== options.reconcile)
709
+ usage('Audit ID does not match the confirmed operation');
710
+ resyncSerial = prior.resyncSerial;
711
+ let proven = false;
712
+ try {
713
+ proven = Boolean(await postcondition(prior.postconditionHash));
714
+ }
715
+ catch { /* A failed read cannot prove an outcome. */ }
716
+ if (!proven)
717
+ throw new ToolError('OUTCOME_UNKNOWN', `Outcome remains unknown; audit ID ${prior.auditId}`, 'Read the exact selected target and compare its full postcondition; never replay an uncertain write', 1);
718
+ await files.write(path, JSON.stringify({ ...prior, state: 'reconciled' }));
719
+ return { bytes: Buffer.alloc(0), projection: { status: 'reconciled', auditId: prior.auditId, operation: capability,
720
+ ...(options.reconciliation ? { verifiedProfile: options.reconciliation.name, verifiedOrigin: options.reconciliation.profile.origin } : {}) } };
721
+ }
722
+ let expected;
723
+ if (contract.id === 'OP-093') {
724
+ const scope = await baselineRead({ ...contract, baselinePath: '/api/dhcp/scopes/get', baselineTarget: ['name'] }, profile, token, parameters, ['reservedLeases']);
725
+ const mac = String(parameters.hardwareAddress).replace(/:/g, '-').toUpperCase();
726
+ if (scope.selected.reservedLeases.some(row => row.hardwareAddress === mac || row.address === parameters.ipAddress)) {
727
+ throw new ToolError('CONFLICT', 'A reservation already uses the selected hardware or IP address', 'Read the selected scope reservations and choose an unused address and hardware identifier', 1);
728
+ }
729
+ }
730
+ if (contract.id === 'OP-030')
731
+ resyncSerial = await soaSerial(profile, token, parameters);
732
+ let expectedState;
733
+ if (contract.baselinePath) {
734
+ let baseline = { selected: {}, preserved: {} };
735
+ if (creatingScope) {
736
+ // The scope setter creates a missing scope but silently modifies an existing one, so creation requires proven absence.
737
+ const exists = await baselineRead(contract, profile, token, parameters, ['name']).then(() => true, error => {
738
+ if (error instanceof ToolError && error.code === 'UPSTREAM_ERROR' && /not found/i.test(error.message))
739
+ return false;
740
+ throw error;
741
+ });
742
+ if (exists)
743
+ throw new ToolError('CONFLICT', 'A scope with this name already exists', 'Supply the exact selected baseline to modify it, or choose an unused scope name', 1);
744
+ }
745
+ else {
746
+ baseline = await baselineRead(contract, profile, token, parameters, baselineFields).catch(error => {
747
+ if (contract.id === 'OP-092' && error instanceof ToolError && error.code === 'UPSTREAM_ERROR' && /not found/i.test(error.message))
748
+ throw new ToolError(error.code, error.message, 'To create a scope omit the baseline and supply name, startingAddress, endingAddress and subnetMask', 1);
749
+ throw error;
750
+ });
751
+ if (canonical(baseline.selected) !== canonical(selectedValues(baselineInput, contract.id === 'OP-092')))
752
+ throw new ToolError('CONFLICT', 'Selected baseline changed after preview', 'Read the selected values and preview a new explicit replacement', 1);
753
+ }
754
+ const selected = selectedValues(Object.fromEntries(baselineFields.map(key => [key, parameters[key]])), contract.id === 'OP-092');
755
+ if (['OP-034', 'OP-113'].includes(contract.id) && Array.isArray(selected.groupPermissions)) {
756
+ const section = String(parameters.section).toLowerCase();
757
+ const mandatory = ['administrators', ...(contract.id === 'OP-034' || section === 'zones' ? ['dns administrators'] : section === 'dhcpserver' ? ['dhcp administrators'] : [])];
758
+ selected.groupPermissions = baselineValue('groupPermissions', [
759
+ ...permissionRows('groupPermissions', selected.groupPermissions).filter(row => !mandatory.includes(String(row.name))),
760
+ ...mandatory.map(name => ({ name, canView: true, canModify: true, canDelete: true })),
761
+ ]);
762
+ }
763
+ if (contract.id === 'OP-109' && Array.isArray(selected.members) && String(parameters.newGroup ?? parameters.group).toLowerCase() === 'administrators') {
764
+ const session = await vendorRead(profile, 'user session get', token);
765
+ redactor.add(session.secret);
766
+ if (!('username' in session.projection))
767
+ throw new ToolError('UPSTREAM_INVALID', 'Authenticated caller identity is missing', 'Check the selected server API version and operation', 1);
768
+ selected.members = baselineValue('members', [...selected.members, session.projection.username]);
769
+ }
770
+ const preserved = { ...baseline.preserved };
771
+ if (contract.id === 'OP-080')
772
+ for (const prefix of ['webServiceTlsCertificate', 'dnsTlsCertificate']) {
773
+ if (Object.hasOwn(selected, `${prefix}Password`))
774
+ selected[`${prefix}Password`] = (parameters[`${prefix}Path`] ?? preserved[`${prefix}Path`]) ? '************' : '';
775
+ }
776
+ const forwarders = selected.forwarders ?? preserved.forwarders;
777
+ if (contract.id === 'OP-080' && Array.isArray(forwarders) && forwarders.length === 0) {
778
+ if (Object.hasOwn(selected, 'forwarderProtocol'))
779
+ selected.forwarderProtocol = 'Udp';
780
+ else
781
+ preserved.forwarderProtocol = 'Udp';
782
+ }
783
+ expectedState = { selected, preserved, ...('unchanged' in baseline ? { unchanged: baseline.unchanged } : {}) };
784
+ expected = postconditionHash(expectedState);
785
+ if (typeof baseline.preserved.proxyPassword === 'string')
786
+ redactor.add(baseline.preserved.proxyPassword);
787
+ Object.assign(parameters, baseline.preserved);
788
+ }
789
+ if (mutating) {
790
+ try {
791
+ await files.create(path, JSON.stringify({ auditId, digest, operation: capability, state: 'intent', postconditionHash: expected, resyncSerial, clusterBefore }));
792
+ }
793
+ catch (error) {
794
+ if (error.code === 'EEXIST')
795
+ throw new ToolError('REPLAY_DENIED', 'This confirmed mutation already has a durable intent', 'Inspect its protected journal entry and reconcile the original audit ID; do not replay', 1);
796
+ throw new ToolError('JOURNAL_FAILED', 'Durable intent could not be recorded', 'Repair the protected journal before executing any mutation', 1);
797
+ }
798
+ }
799
+ const form = new URLSearchParams(Object.entries(parameters).map(([key, value]) => [key,
800
+ ['userPermissions', 'groupPermissions'].includes(key) ? permissionRows(key, value).flatMap(row => [row[key === 'userPermissions' ? 'username' : 'name'], row.canView, row.canModify, row.canDelete]).join('|')
801
+ : key === 'ssoScopes' ? value.join('|')
802
+ : configurationForm(contract.id === 'OP-080' ? settingsFields[key] : contract.id === 'OP-092' ? dhcpFields[key] : ['OP-115', 'OP-142', 'OP-143'].includes(contract.id) ? identityFields[key] : undefined, value)]));
803
+ let body = form.toString();
804
+ let requestPath = contract.path;
805
+ const headers = {};
806
+ if (input.encoding === 'json') {
807
+ body = JSON.stringify(parameters);
808
+ headers['content-type'] = 'application/json';
809
+ }
810
+ if (upload) {
811
+ requestPath += `?${form}`;
812
+ if (input.upload?.format === 'text') {
813
+ headers['content-type'] = 'text/plain';
814
+ body = upload;
815
+ }
816
+ else {
817
+ const boundary = `technitium-${randomUUID()}`;
818
+ headers['content-type'] = `multipart/form-data; boundary=${boundary}`;
819
+ const archive = ['OP-072', 'OP-073', 'OP-085'].includes(contract.id);
820
+ body = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${archive ? 'package.zip' : 'zone.txt'}"\r\nContent-Type: ${archive ? 'application/zip' : 'text/plain'}\r\n\r\n`), upload, Buffer.from(`\r\n--${boundary}--\r\n`)]);
821
+ }
822
+ }
823
+ let refusal;
824
+ try {
825
+ const response = await vendorRequest(profile, requestPath, body, acquisition ? undefined : token, headers, { bytes: 16 * 1024 * 1024, elapsedMs: 10000 });
826
+ // A missing endpoint cannot have applied anything, so it is a definitive refusal for every mutation.
827
+ const unsupported = mutating && response.status === 404;
828
+ if (!unsupported && (response.status !== 200 || response.contentType !== 'application/json'))
829
+ throw new Error('Response unavailable');
830
+ const value = unsupported ? undefined : JSON.parse(response.bytes.toString());
831
+ // An explicit status:error is a clean refusal only when the vendor handler cannot have applied part of the change first.
832
+ const mayApplyPartially = contract.baselinePath !== undefined || clusterBefore !== undefined
833
+ || ['OP-013', 'OP-023', 'OP-060', 'OP-066', 'OP-070', 'OP-071', 'OP-072', 'OP-073', 'OP-074', 'OP-076', 'OP-085', 'OP-104', 'OP-115', 'OP-117', 'OP-142'].includes(contract.id)
834
+ || contract.id === 'OP-029' && parameters.zones !== undefined
835
+ || contract.id === 'OP-116' && parameters.memberOfGroups !== undefined
836
+ || contract.id === 'OP-051' && parameters.newDomain !== undefined && parameters.newDomain !== parameters.domain
837
+ || contract.id === 'OP-022' && parameters.catalog !== undefined
838
+ || capability === 'OP-077.import'
839
+ || ['OP-049', 'OP-051'].includes(contract.id) && (parameters.ptr === true || parameters.createPtrZone === true);
840
+ if (unsupported || mutating && object(value) && value.status === 'error' && !mayApplyPartially) {
841
+ const reason = unsupported ? 'The server does not provide this operation' : vendorMessage(value)?.replace(/\.$/, '');
842
+ refusal = new ToolError(unsupported ? 'UNSUPPORTED_OPERATION' : 'UPSTREAM_ERROR', `Server refused the operation${reason ? `: ${reason}` : ''}; audit ID ${auditId}${profile.tokenKind === 'single-use' ? '; credential consumed' : ''}`, 'The server reported an explicit failure; fix the cause and preview again', 1);
843
+ await files.write(path, JSON.stringify({ auditId, digest, operation: capability, state: 'failed', postconditionHash: expected, resyncSerial, clusterBefore }));
844
+ throw refusal;
845
+ }
846
+ checkStatus(value);
847
+ if (['OP-002', 'OP-003', 'OP-004', 'OP-099'].includes(contract.id)) {
848
+ const credential = ['OP-004', 'OP-099'].includes(contract.id) ? value.response : value;
849
+ if (!object(credential) || typeof credential.token !== 'string' || !credential.token || /[\s\x00-\x1f\x7f]/.test(credential.token))
850
+ throw new Error('Issued credential is invalid');
851
+ redactor.add(credential.token);
852
+ if (contract.id === 'OP-099' && (typeof credential.username !== 'string' || credential.username.toLowerCase() !== String(parameters.user).toLowerCase() || credential.tokenName !== parameters.tokenName))
853
+ throw new Error('Issued token does not match the selected user and name');
854
+ }
855
+ if (contract.id === 'OP-002') {
856
+ if (value.username !== profile.username || typeof value.token !== 'string' || !object(value.info) || !object(value.info.permissions))
857
+ throw new Error('Issued identity does not match the selected profile');
858
+ if (Object.values(value.info.permissions).some(permission => !object(permission) || Object.values(permission).some(flag => typeof flag !== 'boolean')))
859
+ throw new Error('Invalid permission response');
860
+ await storeSession(identity, profile, value.token);
861
+ }
862
+ const localRemoved = contract.id === 'OP-005' ? await removeSession(identity, profile, token) : undefined;
863
+ if (contract.id === 'OP-080' && expectedState && Array.isArray(parameters.tsigKeys) && parameters.tsigKeys.some(row => row.sharedSecret === '')) {
864
+ if (!object(value.response))
865
+ throw new Error('Generated TSIG keys are unavailable');
866
+ const returned = baselineValue('tsigKeys', value.response.tsigKeys);
867
+ validateConfiguration(settingsFields.tsigKeys, returned, true);
868
+ const requested = baselineValue('tsigKeys', parameters.tsigKeys);
869
+ const generated = returned;
870
+ if (generated.length !== requested.length || generated.some((row, i) => row.keyName !== requested[i].keyName || row.algorithmName !== requested[i].algorithmName || !row.sharedSecret || requested[i].sharedSecret !== '' && row.sharedSecret !== requested[i].sharedSecret))
871
+ throw new Error('Generated TSIG keys do not match the selected replacement');
872
+ for (const row of generated)
873
+ redactor.add(row.sharedSecret);
874
+ expectedState.selected.tsigKeys = generated;
875
+ expected = postconditionHash(expectedState);
876
+ await files.write(path, JSON.stringify({ auditId, digest, operation: capability, state: 'intent', postconditionHash: expected }));
877
+ }
878
+ let resolution;
879
+ if (contract.id === 'OP-083') {
880
+ if (!object(value.response) || typeof value.response.temporaryDisableBlockingTill !== 'string' || !Number.isFinite(Date.parse(value.response.temporaryDisableBlockingTill)))
881
+ throw new ToolError('UPSTREAM_INVALID', 'Invalid blocking expiry response', 'Read settings to verify the blocking expiry', 1);
882
+ resolution = { response: { temporaryDisableBlockingTill: value.response.temporaryDisableBlockingTill } };
883
+ }
884
+ if (contract.id === 'OP-077') {
885
+ if (!object(value.response) || !object(value.response.result) || (value.response.warningMessage !== undefined && typeof value.response.warningMessage !== 'string'))
886
+ throw new ToolError('UPSTREAM_INVALID', 'Invalid DNS client response', 'Check the selected server API version', 1);
887
+ resolution = { response: value.response.result,
888
+ ...(mutating && value.response.warningMessage ? { mutation: 'suppressed' } : {}),
889
+ ...(typeof value.response.warningMessage === 'string' ? { warningMessage: value.response.warningMessage } : {}) };
890
+ }
891
+ if (contract.baselinePath && !await postcondition(expected))
892
+ throw new Error('Postcondition is not proven');
893
+ if (clusterBefore && !await postcondition(undefined))
894
+ throw new Error('Selected cluster state is not proven');
895
+ if (contract.id === 'OP-030') {
896
+ let completed = false;
897
+ for (let attempt = 0; attempt < 3 && !completed; attempt++)
898
+ completed = await postcondition(undefined);
899
+ if (!completed)
900
+ throw new Error('Transfer completion is not proven');
901
+ }
902
+ const partial = contract.id === 'OP-029' && object(value.response) && object(value.response.failed) && Object.keys(value.response.failed).length > 0;
903
+ if (mutating)
904
+ await files.write(path, JSON.stringify({ auditId, digest, operation: capability, state: partial ? 'partial' : 'completed', postconditionHash: expected, resyncSerial, clusterBefore }));
905
+ return { bytes: response.bytes, projection: { status: partial ? 'partial' : 'ok', operation: capability,
906
+ ...(partial && object(value.response) ? { deleted: value.response.deleted, failed: value.response.failed } : {}), ...(mutating ? { auditId } : {}),
907
+ ...resolution,
908
+ ...(clusterBefore ? { cluster: clusterPreview(contract.id, parameters, clusterBefore) } : {}),
909
+ ...(contract.id === 'OP-002' ? { username: value.username, stored: true, info: { permissions: object(value.info) ? value.info.permissions : {} } } : {}),
910
+ ...(contract.id === 'OP-005' ? { revoked: true, localRemoved } : {}),
911
+ ...(profile.tokenKind === 'single-use' ? { credential: 'consumed' } : {}) } };
912
+ }
913
+ catch (error) {
914
+ if (contract.id === 'OP-005') {
915
+ const localRemoved = await removeSession(identity, profile, token);
916
+ await files.write(path, JSON.stringify({ auditId, digest, operation: capability, state: 'revoke-failed', localRemoved }));
917
+ return { bytes: Buffer.alloc(0), projection: { status: 'revoke-failed', operation: capability, auditId, revoked: false, localRemoved } };
918
+ }
919
+ if (error === refusal || error instanceof ToolError && ['AUTH_REQUIRED', 'TWO_FACTOR_REQUIRED'].includes(error.code))
920
+ throw error;
921
+ if (!mutating)
922
+ throw error;
923
+ // The durable intent deliberately survives transport, handler and final-read failures.
924
+ throw new ToolError('OUTCOME_UNKNOWN', `Mutation may have applied; audit ID ${auditId}${profile.tokenKind === 'single-use' ? '; credential consumed-or-uncertain' : ''}`, 'Do not replay; read the exact selected target, or use --reconcile <audit-id> with the original input and confirmation', 1);
925
+ }
926
+ }
927
+ async function identityResponse(profile, path, body, token) {
928
+ const response = await vendorRequest(profile, path, body, token, {}, { bytes: 1024 * 1024, elapsedMs: 10000 });
929
+ if (response.status !== 200 || response.contentType !== 'application/json')
930
+ throw new ToolError('UPSTREAM_INVALID', 'Identity preflight failed', 'Check the selected server and identity', 1);
931
+ let value;
932
+ try {
933
+ value = JSON.parse(response.bytes.toString());
934
+ }
935
+ catch {
936
+ throw new ToolError('UPSTREAM_INVALID', 'Invalid identity response', 'Check the selected API version', 1);
937
+ }
938
+ checkStatus(value);
939
+ return value;
940
+ }