sap-ai-dev-toolkit 0.9.9913 → 0.9.9914

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.
@@ -0,0 +1,381 @@
1
+ // API Management API proxy bundles (zip) for Transport.svc import/export.
2
+ // Layout follows SAP's apibusinesshub-api-recipes sample bundles: every file lives under APIProxy/
3
+ // (root <Name>.xml, APIProxyEndPoint/, APITargetEndPoint/, Policy/, FileResource/). Exported bundles are
4
+ // edited as strings, touching only the elements being changed, so unknown content survives a re-import.
5
+ import { strFromU8, strToU8, zipSync } from 'fflate';
6
+ import { IntegrationError } from './integration-http.mjs';
7
+
8
+ const XML_HEADER = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
9
+ const EMPTY_FLOWS = '<faultRules/><preFlow><name>PreFlow</name></preFlow><postFlow><name>PostFlow</name></postFlow><conditionalFlows/>';
10
+ const NS = 'http://www.sap.com/apimgmt';
11
+
12
+ export function xmlEscape(value) {
13
+ return String(value ?? '')
14
+ .replaceAll('&', '&amp;')
15
+ .replaceAll('<', '&lt;')
16
+ .replaceAll('>', '&gt;')
17
+ .replaceAll('"', '&quot;')
18
+ .replaceAll("'", '&apos;');
19
+ }
20
+
21
+ function xmlUnescape(value) {
22
+ return value.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&quot;', '"').replaceAll('&apos;', "'").replaceAll('&amp;', '&');
23
+ }
24
+
25
+ function proxyXml({ name, title, description, serviceType }) {
26
+ return `${XML_HEADER}<APIProxy><name>${xmlEscape(name)}</name><title>${xmlEscape(title)}</title>`
27
+ + `<description>${xmlEscape(description)}</description>`
28
+ + `<service_code>${serviceType === 'odata' ? 'ODATA' : 'REST'}</service_code>`
29
+ + '<proxyEndPoints><proxyEndPoint><proxyEndPointName>default</proxyEndPointName></proxyEndPoint></proxyEndPoints>'
30
+ + '<targetEndPoints><targetEndPoint>default</targetEndPoint></targetEndPoints>'
31
+ + '<policies/><fileResources/></APIProxy>';
32
+ }
33
+
34
+ function proxyEndPointXml(basePath) {
35
+ return `${XML_HEADER}<ProxyEndPoint default="true"><name>default</name><base_path>${xmlEscape(basePath)}</base_path>`
36
+ + '<properties/><routeRules><routeRule><name>default</name><targetEndPointName>default</targetEndPointName>'
37
+ + '<sequence>1</sequence><faultRules/></routeRule></routeRules>'
38
+ + `${EMPTY_FLOWS}</ProxyEndPoint>`;
39
+ }
40
+
41
+ function targetHead(target) {
42
+ return target.provider
43
+ ? `<provider_id>${xmlEscape(target.provider)}</provider_id><relativePath>${xmlEscape(target.relativePath)}</relativePath>`
44
+ : `<url>${xmlEscape(target.url)}</url><provider_id>NONE</provider_id>`;
45
+ }
46
+
47
+ function targetEndPointXml(target) {
48
+ return `${XML_HEADER}<TargetEndPoint><name>default</name>${targetHead(target)}<isDefault>true</isDefault><properties/>${EMPTY_FLOWS}</TargetEndPoint>`;
49
+ }
50
+
51
+ /** Build a policy-free API proxy bundle targeting an API provider or a fixed https URL. */
52
+ export function buildApiProxyBundle({ name, title, description = '', basePath, serviceType, target }) {
53
+ return zipSync({
54
+ [`APIProxy/${name}.xml`]: strToU8(proxyXml({ name, title: title ?? name, description, serviceType })),
55
+ 'APIProxy/APIProxyEndPoint/default.xml': strToU8(proxyEndPointXml(basePath)),
56
+ 'APIProxy/APITargetEndPoint/default.xml': strToU8(targetEndPointXml(target))
57
+ });
58
+ }
59
+
60
+ // ---------------------------------------------------------------- string XML editing
61
+
62
+ const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<(\/?)([A-Za-z_][\w.:-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
63
+
64
+ /** Direct child elements inside [from, to): name plus outer and content offsets. */
65
+ function childElements(xml, from, to) {
66
+ const result = [];
67
+ const stack = [];
68
+ const pattern = new RegExp(TOKEN.source, 'g');
69
+ pattern.lastIndex = from;
70
+ let match;
71
+ while ((match = pattern.exec(xml)) && match.index < to) {
72
+ const [text, closing, name, , selfClosing] = match;
73
+ if (!name) continue;
74
+ const end = match.index + text.length;
75
+ if (closing) {
76
+ const open = stack.pop();
77
+ if (open?.name !== name) throw new IntegrationError(`API proxy bundle XML is malformed near </${name}>`);
78
+ if (!stack.length) result.push({ name, start: open.start, contentStart: open.contentStart, contentEnd: match.index, end, selfClosing: false });
79
+ } else if (selfClosing) {
80
+ if (!stack.length) result.push({ name, start: match.index, contentStart: end, contentEnd: end, end, selfClosing: true });
81
+ } else {
82
+ stack.push({ name, start: match.index, contentStart: end });
83
+ }
84
+ }
85
+ if (stack.length) throw new IntegrationError(`API proxy bundle XML is malformed: <${stack.at(-1).name}> is not closed`);
86
+ return result;
87
+ }
88
+
89
+ const children = (xml, parent) => childElements(xml, parent.contentStart, parent.contentEnd);
90
+ const textOf = (xml, element) => xmlUnescape(xml.slice(element.contentStart, element.contentEnd).trim());
91
+ const splice = (xml, start, end, insert) => `${xml.slice(0, start)}${insert}${xml.slice(end)}`;
92
+
93
+ /** Element at a path of child names below the document root ([] = root), or undefined. */
94
+ function locate(xml, path) {
95
+ let element = childElements(xml, 0, xml.length)[0];
96
+ if (!element) throw new IntegrationError('API proxy bundle XML has no root element');
97
+ for (const name of path) {
98
+ element = children(xml, element).find(child => child.name === name);
99
+ if (!element) return undefined;
100
+ }
101
+ return element;
102
+ }
103
+
104
+ /** Turn <x .../> into <x ...>inner</x>, keeping attributes. */
105
+ function expand(xml, element, inner) {
106
+ const openTag = xml.slice(element.start, element.end).replace(/\s*\/>$/, '>');
107
+ return splice(xml, element.start, element.end, `${openTag}${inner}</${element.name}>`);
108
+ }
109
+
110
+ /** Make sure parentPath/name exists as an open element; new elements go before the first `before` sibling. */
111
+ function ensureChild(xml, parentPath, name, inner = '', before = []) {
112
+ let parent = locate(xml, parentPath);
113
+ if (parent.selfClosing) {
114
+ xml = expand(xml, parent, '');
115
+ parent = locate(xml, parentPath);
116
+ }
117
+ const siblings = children(xml, parent);
118
+ const existing = siblings.find(child => child.name === name);
119
+ if (existing) return existing.selfClosing ? expand(xml, existing, inner) : xml;
120
+ const anchor = siblings.find(child => before.includes(child.name));
121
+ const at = anchor ? anchor.start : parent.contentEnd;
122
+ return splice(xml, at, at, `<${name}>${inner}</${name}>`);
123
+ }
124
+
125
+ /** Replace a root child's text, or insert it after the first present `after` sibling (else first). */
126
+ function setRootText(xml, name, value, after = []) {
127
+ const root = locate(xml, []);
128
+ const element = `<${name}>${xmlEscape(value)}</${name}>`;
129
+ const siblings = children(xml, root);
130
+ const existing = siblings.find(child => child.name === name);
131
+ if (existing) return splice(xml, existing.start, existing.end, element);
132
+ const anchor = after.map(candidate => siblings.find(child => child.name === candidate)).find(Boolean);
133
+ const at = anchor ? anchor.end : root.contentStart;
134
+ return splice(xml, at, at, element);
135
+ }
136
+
137
+ /** Add or replace <entryTag type="T">name</entryTag> inside the root <listTag>. */
138
+ function registerRootEntry(xml, listTag, entryTag, type, name, before = []) {
139
+ xml = ensureChild(xml, [], listTag, '', before);
140
+ const listElement = locate(xml, [listTag]);
141
+ const entry = `<${entryTag} type="${xmlEscape(type)}">${xmlEscape(name)}</${entryTag}>`;
142
+ const same = children(xml, listElement).find(child => child.name === entryTag && textOf(xml, child) === name);
143
+ return same ? splice(xml, same.start, same.end, entry) : splice(xml, listElement.contentEnd, listElement.contentEnd, entry);
144
+ }
145
+
146
+ function rootEntryNames(xml, listTag, entryTag) {
147
+ const listElement = locate(xml, [listTag]);
148
+ return listElement ? children(xml, listElement).filter(child => child.name === entryTag).map(child => textOf(xml, child)) : [];
149
+ }
150
+
151
+ /** Append a policy step to an endpoint flow (created when missing); an existing step for the policy is kept. */
152
+ function addStep(xml, flowTag, phase, policyName, condition) {
153
+ const flowName = flowTag === 'preFlow' ? 'PreFlow' : 'PostFlow';
154
+ xml = ensureChild(xml, [], flowTag, `<name>${flowName}</name>`, flowTag === 'preFlow' ? ['postFlow', 'conditionalFlows'] : ['conditionalFlows']);
155
+ xml = ensureChild(xml, [flowTag], phase, `<isRequest>${phase === 'request'}</isRequest><steps></steps>`, phase === 'request' ? ['response'] : []);
156
+ xml = ensureChild(xml, [flowTag, phase], 'steps');
157
+ const steps = locate(xml, [flowTag, phase, 'steps']);
158
+ let sequence = 0;
159
+ for (const step of children(xml, steps).filter(child => child.name === 'step')) {
160
+ const fields = children(xml, step);
161
+ const field = name => fields.find(child => child.name === name);
162
+ if (field('policy_name') && textOf(xml, field('policy_name')) === policyName) return { xml, added: false };
163
+ const value = Number(field('sequence') ? textOf(xml, field('sequence')) : NaN);
164
+ if (Number.isFinite(value)) sequence = Math.max(sequence, value);
165
+ }
166
+ const step = `<step><policy_name>${xmlEscape(policyName)}</policy_name><condition>${xmlEscape(condition ?? '')}</condition><sequence>${sequence + 1}</sequence></step>`;
167
+ return { xml: splice(xml, steps.contentEnd, steps.contentEnd, step), added: true };
168
+ }
169
+
170
+ // ---------------------------------------------------------------- exported bundle layout
171
+
172
+ function endpointFile(files, folder, kind) {
173
+ const candidates = [...files.keys()].filter(path => path.startsWith(folder) && path.endsWith('.xml') && !path.slice(folder.length).includes('/'));
174
+ const chosen = candidates.find(path => path === `${folder}default.xml`) ?? (candidates.length === 1 ? candidates[0] : undefined);
175
+ if (!chosen) {
176
+ throw new IntegrationError(candidates.length
177
+ ? `API proxy has several ${kind} endpoints and none is named default`
178
+ : `API proxy bundle has no ${kind} endpoint`);
179
+ }
180
+ return chosen;
181
+ }
182
+
183
+ function proxyLayout(files, proxyName) {
184
+ const anyPath = [...files.keys()].find(path => /(?:^|\/)APIProxy\//.test(path));
185
+ if (!anyPath) throw new IntegrationError('API proxy bundle has no APIProxy/ folder');
186
+ const dir = anyPath.slice(0, anyPath.indexOf('APIProxy/') + 'APIProxy/'.length);
187
+ const rootCandidates = [...files.keys()].filter(path => path.startsWith(dir) && path.endsWith('.xml') && !path.slice(dir.length).includes('/'));
188
+ const root = rootCandidates.find(path => path === `${dir}${proxyName}.xml`) ?? (rootCandidates.length === 1 ? rootCandidates[0] : undefined);
189
+ if (!root) throw new IntegrationError('API proxy bundle has no root APIProxy XML file');
190
+ return {
191
+ dir,
192
+ root,
193
+ proxy: () => endpointFile(files, `${dir}APIProxyEndPoint/`, 'proxy'),
194
+ target: () => endpointFile(files, `${dir}APITargetEndPoint/`, 'target')
195
+ };
196
+ }
197
+
198
+ const readText = (files, path) => strFromU8(files.get(path));
199
+ const writeText = (files, path, text) => files.set(path, strToU8(text));
200
+
201
+ /** Names of the policies registered in the root APIProxy XML. */
202
+ export function bundlePolicyNames(files, proxyName) {
203
+ return rootEntryNames(readText(files, proxyLayout(files, proxyName).root), 'policies', 'policy');
204
+ }
205
+
206
+ /**
207
+ * Patch an exported bundle in place (Map path → bytes): root title/description/service_code when given,
208
+ * proxy endpoint base_path, and the target head (url/provider_id/relativePath). Policies, steps,
209
+ * file resources and every other element are kept.
210
+ */
211
+ export function patchProxyBundle(files, proxyName, { title, description, serviceType, basePath, target }) {
212
+ const layout = proxyLayout(files, proxyName);
213
+ let root = readText(files, layout.root);
214
+ if (title !== undefined) root = setRootText(root, 'title', title, ['name']);
215
+ if (description !== undefined) root = setRootText(root, 'description', description, ['title', 'name']);
216
+ if (serviceType) root = setRootText(root, 'service_code', serviceType === 'odata' ? 'ODATA' : 'REST', ['description', 'title', 'name']);
217
+ writeText(files, layout.root, root);
218
+
219
+ const proxyPath = layout.proxy();
220
+ writeText(files, proxyPath, setRootText(readText(files, proxyPath), 'base_path', basePath, ['name']));
221
+
222
+ const targetPath = layout.target();
223
+ let targetXml = readText(files, targetPath);
224
+ for (let head; (head = children(targetXml, locate(targetXml, [])).find(child => ['url', 'provider_id', 'relativePath'].includes(child.name)));) {
225
+ targetXml = splice(targetXml, head.start, head.end, '');
226
+ }
227
+ const targetRoot = locate(targetXml, []);
228
+ const nameElement = children(targetXml, targetRoot).find(child => child.name === 'name');
229
+ const at = nameElement ? nameElement.end : targetRoot.contentStart;
230
+ writeText(files, targetPath, splice(targetXml, at, at, targetHead(target)));
231
+ return { preservedPolicies: rootEntryNames(root, 'policies', 'policy') };
232
+ }
233
+
234
+ // ---------------------------------------------------------------- policy templates
235
+
236
+ const policyOpen = (element, async = 'false', extra = '') => `<${element} async="${async}" continueOnError="false" enabled="true"${extra} xmlns="${NS}">`;
237
+ const headers = entries => `<Headers>${entries.map(([name, value]) => (value === undefined
238
+ ? `<Header name="${xmlEscape(name)}"/>`
239
+ : `<Header name="${xmlEscape(name)}">${xmlEscape(value)}</Header>`)).join('')}</Headers>`;
240
+
241
+ /**
242
+ * Expand one requested policy into policy documents ({name, type, xml, steps, fileResource?}).
243
+ * Step placement defaults: proxy endpoint, preFlow, request phase; templates override where fixed.
244
+ */
245
+ export function policyDocuments(spec) {
246
+ const { name, template, params = {}, condition } = spec;
247
+ const place = (defaults = {}) => ({
248
+ endpoint: spec.endpoint ?? defaults.endpoint ?? 'proxy',
249
+ flow: spec.flow === 'post' || (!spec.flow && defaults.flow === 'post') ? 'postFlow' : 'preFlow',
250
+ phase: spec.phase ?? defaults.phase ?? 'request',
251
+ condition
252
+ });
253
+ const single = (type, xml, steps = [place()]) => [{ name, type, xml, steps }];
254
+ switch (template) {
255
+ case 'verify_api_key':
256
+ return single('VerifyAPIKey', `${policyOpen('VerifyAPIKey', 'true')}<APIKey ref="${xmlEscape(params.key_ref)}"/></VerifyAPIKey>`);
257
+ case 'quota':
258
+ return single('Quota', `${policyOpen('Quota')}<Allow count="${params.count}"/><Interval>${params.interval}</Interval>`
259
+ + `<Distributed>true</Distributed><Synchronous>true</Synchronous><TimeUnit>${xmlEscape(params.time_unit)}</TimeUnit></Quota>`);
260
+ case 'spike_arrest':
261
+ return single('SpikeArrest', `${policyOpen('SpikeArrest', 'true')}<Rate>${xmlEscape(params.rate)}</Rate></SpikeArrest>`);
262
+ case 'cors': {
263
+ const entries = [
264
+ ['Access-Control-Allow-Origin', params.allow_origin],
265
+ ['Access-Control-Allow-Methods', params.allow_methods],
266
+ ['Access-Control-Allow-Headers', params.allow_headers]
267
+ ];
268
+ if (params.expose_headers !== undefined) entries.push(['Access-Control-Expose-Headers', params.expose_headers]);
269
+ if (params.max_age !== undefined) entries.push(['Access-Control-Max-Age', String(params.max_age)]);
270
+ return single('AssignMessage', `${policyOpen('AssignMessage')}<Add>${headers(entries)}</Add>`
271
+ + '<IgnoreUnresolvedVariables>false</IgnoreUnresolvedVariables><AssignTo createNew="false" type="response">response</AssignTo></AssignMessage>',
272
+ [place({ phase: 'response' })]);
273
+ }
274
+ case 'assign_headers': {
275
+ const remove = params.remove?.length ? `<Remove>${headers(params.remove.map(header => [header]))}</Remove>` : '';
276
+ const set = Object.keys(params.set ?? {}).length ? `<Set>${headers(Object.entries(params.set))}</Set>` : '';
277
+ return single('AssignMessage', `${policyOpen('AssignMessage')}${remove}${set}<IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>`
278
+ + `<AssignTo createNew="false" transport="http" type="${params.message}"/></AssignMessage>`,
279
+ [place({ phase: params.message })]);
280
+ }
281
+ case 'oauth_verify_access_token':
282
+ return single('OAuthV2', `${policyOpen('OAuthV2')}<ExternalAuthorization>false</ExternalAuthorization><Operation>VerifyAccessToken</Operation>`
283
+ + '<GenerateResponse enabled="true"/><SupportedGrantTypes/><Tokens/></OAuthV2>');
284
+ case 'ip_allow': {
285
+ const rules = params.addresses.map(address => {
286
+ const [ip, mask = '32'] = address.split('/');
287
+ return `<MatchRule action="ALLOW"><SourceAddress mask="${mask}">${ip}</SourceAddress></MatchRule>`;
288
+ }).join('');
289
+ return single('AccessControl', `${policyOpen('AccessControl', 'true')}<IPRules noRuleMatchAction="DENY">${rules}</IPRules></AccessControl>`);
290
+ }
291
+ case 'response_cache': {
292
+ const fragments = (params.key_fragments?.length ? params.key_fragments : ['request.uri'])
293
+ .map(ref => `<KeyFragment ref="${xmlEscape(ref)}"></KeyFragment>`).join('');
294
+ // ResponseCache runs twice under one name: lookup on the request, populate on the response.
295
+ return single('ResponseCache', `${policyOpen('ResponseCache')}<CacheKey>${fragments}</CacheKey>`
296
+ + `<ExpirySettings><TimeoutInSec>${params.timeout_seconds}</TimeoutInSec></ExpirySettings></ResponseCache>`, [
297
+ { endpoint: 'proxy', flow: 'preFlow', phase: 'request', condition },
298
+ { endpoint: 'proxy', flow: 'postFlow', phase: 'response', condition }
299
+ ]);
300
+ }
301
+ case 'basic_auth_from_kvm': {
302
+ const steps = [place({ endpoint: 'target' })];
303
+ const user = `private.${name}.user`;
304
+ const password = `private.${name}.password`;
305
+ const get = (variable, entry) => `<Get assignTo="${xmlEscape(variable)}"><Key><Parameter>${xmlEscape(entry)}</Parameter></Key></Get>`;
306
+ return [
307
+ {
308
+ name: `${name}_kvm`,
309
+ type: 'KeyValueMapOperations',
310
+ xml: `<KeyValueMapOperations xmlns="${NS}" mapIdentifier="${xmlEscape(params.kvm)}" async="true" continueOnError="false" enabled="true">`
311
+ + `${get(user, params.user_key)}${get(password, params.password_key)}<Scope>environment</Scope></KeyValueMapOperations>`,
312
+ steps
313
+ },
314
+ {
315
+ name,
316
+ type: 'BasicAuth',
317
+ xml: `${policyOpen('BasicAuthentication', 'true')}<Operation>Encode</Operation><IgnoreUnresolvedVariables>false</IgnoreUnresolvedVariables>`
318
+ + `<User ref="${xmlEscape(user)}"></User><Password ref="${xmlEscape(password)}"></Password>`
319
+ + '<AssignTo createNew="false">request.header.Authorization</AssignTo></BasicAuthentication>',
320
+ steps
321
+ }
322
+ ];
323
+ }
324
+ case 'raise_fault': {
325
+ const payload = params.payload === undefined ? ''
326
+ : `<Payload contentType="${xmlEscape(params.content_type ?? 'application/json')}" variablePrefix="@" variableSuffix="#">${xmlEscape(params.payload)}</Payload>`;
327
+ return single('RaiseFault', `${policyOpen('RaiseFault', 'true')}<FaultResponse><Set>${payload}<StatusCode>${params.status_code}</StatusCode>`
328
+ + `<ReasonPhrase>${xmlEscape(params.reason_phrase)}</ReasonPhrase></Set></FaultResponse><IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables></RaiseFault>`);
329
+ }
330
+ case 'javascript': {
331
+ const [document] = single('Javascript', `${policyOpen('Javascript', 'false', ' timeLimit="2000"')}<ResourceURL>jsc://${params.file}.js</ResourceURL></Javascript>`);
332
+ return [{ ...document, fileResource: { name: params.file, type: 'js', content: params.script } }];
333
+ }
334
+ case 'custom':
335
+ return single(params.type, params.xml);
336
+ default:
337
+ throw new IntegrationError(`Unknown policy template ${template}`);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Attach policies to an exported bundle in place: write Policy/<name>.xml (and FileResource/<file>.js),
343
+ * register them in the root XML, and add flow steps. Re-attaching a name replaces its XML and keeps its step.
344
+ */
345
+ export function attachPolicies(files, proxyName, specs) {
346
+ const documents = specs.flatMap(policyDocuments);
347
+ const seen = new Set();
348
+ for (const name of [...documents.map(document => document.name), ...documents.filter(document => document.fileResource).map(document => `${document.fileResource.name}.js`)]) {
349
+ if (seen.has(name)) throw new IntegrationError(`Policy or script name ${name} is used twice in one request`);
350
+ seen.add(name);
351
+ }
352
+ const layout = proxyLayout(files, proxyName);
353
+ let root = readText(files, layout.root);
354
+ const endpoints = {};
355
+ const endpointXml = endpoint => {
356
+ if (!endpoints[endpoint]) {
357
+ const path = layout[endpoint]();
358
+ endpoints[endpoint] = { path, xml: readText(files, path) };
359
+ }
360
+ return endpoints[endpoint];
361
+ };
362
+ let stepsAdded = 0;
363
+ for (const document of documents) {
364
+ const body = document.xml.trimStart().startsWith('<?xml') ? document.xml : `${XML_HEADER}\n${document.xml}`;
365
+ writeText(files, `${layout.dir}Policy/${document.name}.xml`, body);
366
+ root = registerRootEntry(root, 'policies', 'policy', document.type, document.name, ['fileResources']);
367
+ if (document.fileResource) {
368
+ writeText(files, `${layout.dir}FileResource/${document.fileResource.name}.js`, document.fileResource.content);
369
+ root = registerRootEntry(root, 'fileResources', 'fileResource', document.fileResource.type, document.fileResource.name);
370
+ }
371
+ for (const step of document.steps) {
372
+ const target = endpointXml(step.endpoint);
373
+ const { xml, added } = addStep(target.xml, step.flow, step.phase, document.name, step.condition);
374
+ target.xml = xml;
375
+ if (added) stepsAdded += 1;
376
+ }
377
+ }
378
+ writeText(files, layout.root, root);
379
+ for (const { path, xml } of Object.values(endpoints)) writeText(files, path, xml);
380
+ return { policies: documents.map(document => document.name), stepsAdded };
381
+ }