@llm4ts/shell 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/flows/epic-stories.js +9 -3
  2. package/flows/lib/epic-stories.js +24 -4
  3. package/kits/soap-ace/README.md +313 -0
  4. package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +102 -0
  5. package/kits/soap-ace/fixtures/demo-bank-soap/design/api-design.md +620 -0
  6. package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaConti.xml +3 -0
  7. package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaMovimenti.xml +3 -0
  8. package/kits/soap-ace/fixtures/demo-bank-soap/responses/confermaBonifico.xml +3 -0
  9. package/kits/soap-ace/fixtures/demo-bank-soap/responses/dettaglioConto.xml +3 -0
  10. package/kits/soap-ace/fixtures/demo-bank-soap/responses/inserisciBonifico.xml +3 -0
  11. package/kits/soap-ace/fixtures/demo-bank-soap/responses/revocaBonifico.xml +3 -0
  12. package/kits/soap-ace/fixtures/demo-bank-soap/soapui/DemoBank-soapui-project.xml +39 -0
  13. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/DemoBank.wsdl +186 -0
  14. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/LegacyRpc.wsdl +29 -0
  15. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/banking.xsd +10 -0
  16. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/bonifici.xsd +107 -0
  17. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/common.xsd +93 -0
  18. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/conti.xsd +71 -0
  19. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/movimenti.xsd +55 -0
  20. package/kits/soap-ace/flows/lib/soap/Analysis.js +508 -0
  21. package/kits/soap-ace/flows/lib/soap/Auth.js +179 -0
  22. package/kits/soap-ace/flows/lib/soap/Call.js +186 -0
  23. package/kits/soap-ace/flows/lib/soap/CallPolicy.js +50 -0
  24. package/kits/soap-ace/flows/lib/soap/Catalog.js +157 -0
  25. package/kits/soap-ace/flows/lib/soap/Classification.js +254 -0
  26. package/kits/soap-ace/flows/lib/soap/Design.js +497 -0
  27. package/kits/soap-ace/flows/lib/soap/DesignProposal.js +204 -0
  28. package/kits/soap-ace/flows/lib/soap/Discover.js +139 -0
  29. package/kits/soap-ace/flows/lib/soap/Envelope.js +132 -0
  30. package/kits/soap-ace/flows/lib/soap/Epic.js +239 -0
  31. package/kits/soap-ace/flows/lib/soap/Instance.js +675 -0
  32. package/kits/soap-ace/flows/lib/soap/Mapping.js +130 -0
  33. package/kits/soap-ace/flows/lib/soap/Masking.js +508 -0
  34. package/kits/soap-ace/flows/lib/soap/OpenApi.js +288 -0
  35. package/kits/soap-ace/flows/lib/soap/Samples.js +287 -0
  36. package/kits/soap-ace/flows/lib/soap/Scenarios.js +132 -0
  37. package/kits/soap-ace/flows/lib/soap/SoapUiImport.js +200 -0
  38. package/kits/soap-ace/flows/lib/soap/Stub.js +34 -0
  39. package/kits/soap-ace/flows/lib/soap/Transport.js +167 -0
  40. package/kits/soap-ace/flows/lib/soap/Wsdl.js +533 -0
  41. package/kits/soap-ace/flows/lib/soap/Xml.js +453 -0
  42. package/kits/soap-ace/flows/lib/soap/Xsd.js +471 -0
  43. package/kits/soap-ace/flows/lib/soap/Yaml.js +309 -0
  44. package/kits/soap-ace/flows/soap-design.js +167 -0
  45. package/kits/soap-ace/flows/soap-discover.js +85 -0
  46. package/kits/soap-ace/flows/soap-epic.js +204 -0
  47. package/kits/soap-ace/flows/soap-sample.js +265 -0
  48. package/kits/soap-ace/packs/ace12-rest/api-style.md +63 -0
  49. package/kits/soap-ace/packs/ace12-rest/pack.md +18 -0
  50. package/kits/soap-ace/packs/ace12-rest/prompts/implement.md +8 -0
  51. package/kits/soap-ace/packs/ace12-rest/reviewers/contract.md +5 -0
  52. package/kits/soap-ace/patterns/PAT-ACE-001-esito-to-problem.md +8 -0
  53. package/kits/soap-ace/patterns/PAT-ACE-002-fault-to-problem.md +8 -0
  54. package/kits/soap-ace/patterns/PAT-ACE-003-amounts-and-enums.md +8 -0
  55. package/kits/soap-ace/patterns/PAT-ACE-004-pagination.md +7 -0
  56. package/kits/soap-ace/patterns/PAT-ACE-005-nil-and-absence.md +7 -0
  57. package/kits/soap-ace/scaffolds/ace12-rest-api/CONTRIBUTING.md +56 -0
  58. package/kits/soap-ace/scaffolds/ace12-rest-api/README.md +16 -0
  59. package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates-container.sh +8 -0
  60. package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates.sh +41 -0
  61. package/package.json +4 -4
@@ -0,0 +1,132 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import { elementByName, operationByName } from "./Catalog.js";
4
+ import { readIfPresent } from "./Discover.js";
5
+ import { skeletonYaml, validateInstance } from "./Instance.js";
6
+ import { renderRequestFile, samplePaths, toSampleName } from "./Samples.js";
7
+ // Scenario sets: for one operation the reasoning seat proposes a handful of
8
+ // request bodies that together exercise it — the happy path, an empty
9
+ // result, a pagination boundary, a business fault, a schema edge. The model
10
+ // only drafts; each body is validated against the XSD and written as an
11
+ // ordinary request file (with its problems listed at the top), so the user
12
+ // reviews and edits before anything is called. Existing files are kept.
13
+ export class ProposedScenario extends Schema.Class("ProposedScenario")({
14
+ name: Schema.String,
15
+ purpose: Schema.String,
16
+ body: Schema.Json
17
+ }) {
18
+ }
19
+ export class ScenarioSet extends Schema.Class("ScenarioSet")({
20
+ scenarios: Schema.Array(ProposedScenario)
21
+ }) {
22
+ }
23
+ const scenarioJsonSchema = {
24
+ type: "object",
25
+ required: ["scenarios"],
26
+ properties: {
27
+ scenarios: {
28
+ type: "array",
29
+ items: {
30
+ type: "object",
31
+ required: ["name", "purpose", "body"],
32
+ properties: {
33
+ name: { type: "string", description: "kebab-case, e.g. happy-path" },
34
+ purpose: { type: "string", description: "what the scenario exercises, one sentence" },
35
+ body: { type: "object", description: "the request body, shaped like the skeleton" }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ };
41
+ /** JSON from a model → request values: every leaf a string, as XML text is. */
42
+ export const toYamlValue = (value) => {
43
+ if (value === null)
44
+ return null;
45
+ if (typeof value === "string")
46
+ return value;
47
+ if (typeof value === "number" || typeof value === "boolean")
48
+ return String(value);
49
+ if (Array.isArray(value))
50
+ return value.map(toYamlValue);
51
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toYamlValue(item)]));
52
+ };
53
+ export const scenarioPrompt = (options) => {
54
+ const operation = operationByName(options.catalog, options.operation);
55
+ const input = operation === undefined ? undefined : elementByName(options.catalog, operation.input);
56
+ if (operation === undefined || input === undefined)
57
+ return undefined;
58
+ const output = operation.output === undefined ? undefined : elementByName(options.catalog, operation.output);
59
+ const seeds = [...options.seeds].map(([field, value]) => `- ${field}: ${value}`);
60
+ return [
61
+ `You are preparing sample requests to explore the SOAP operation ${operation.name} of a bank's backend,`,
62
+ "before an equivalent REST API is designed. Propose 3 to 6 scenarios that together show how it really",
63
+ "behaves. Cover, where the operation allows it: the happy path; an empty or not-found result; a",
64
+ "pagination boundary; one business error (an esito KO code or a SOAP fault); one schema edge (maximum",
65
+ "lengths, optional fields left out, optional fields all present).",
66
+ "",
67
+ `Operation: ${operation.name} (${options.operationClass})`,
68
+ ...(operation.documentation === undefined ? [] : [`Documentation: ${operation.documentation}`]),
69
+ `Faults: ${operation.faults.map((fault) => fault.name).join(", ") || "none declared"}`,
70
+ "",
71
+ "The request body, as YAML with type notes (required fields filled, optional ones commented):",
72
+ "```yaml",
73
+ ...skeletonYaml(options.catalog, input, 0),
74
+ "```",
75
+ ...(output === undefined
76
+ ? []
77
+ : [
78
+ "",
79
+ "The response body shape:",
80
+ "```yaml",
81
+ ...skeletonYaml(options.catalog, output, 0, { maxDepth: 3 }),
82
+ "```"
83
+ ]),
84
+ "",
85
+ "Rules:",
86
+ "- Use only synthetic data. Never invent a real person's name, tax code, or account.",
87
+ "- Reuse these identifiers seen in earlier samples wherever an identifier is needed; they are masked",
88
+ " test values the service is likely to know:",
89
+ ...(seeds.length === 0
90
+ ? [" (none yet: use the examples in the skeleton)"]
91
+ : seeds.map((seed) => ` ${seed}`)),
92
+ "- Every value is a string. Respect the type notes (patterns, lengths, enumerations, digits).",
93
+ "- Omit optional fields you do not need; never add fields that are not in the skeleton.",
94
+ "- Names are kebab-case and unique; the purpose says what the scenario should reveal.",
95
+ "",
96
+ 'Reply with JSON: {"scenarios": [{"name": "...", "purpose": "...", "body": {...}}]}.'
97
+ ].join("\n");
98
+ };
99
+ export const proposeScenarios = (options) => Effect.gen(function* () {
100
+ const prompt = scenarioPrompt(options);
101
+ const operation = operationByName(options.catalog, options.operation);
102
+ const input = operation === undefined ? undefined : elementByName(options.catalog, operation.input);
103
+ if (prompt === undefined || input === undefined)
104
+ return [];
105
+ const proposal = yield* options.reasoning.executeStructured(prompt, ScenarioSet, scenarioJsonSchema);
106
+ const written = [];
107
+ const names = new Set();
108
+ for (const scenario of proposal.scenarios) {
109
+ let name = toSampleName(scenario.name);
110
+ for (let suffix = 2; names.has(name); suffix++)
111
+ name = `${toSampleName(scenario.name).slice(0, 58)}-${suffix}`;
112
+ names.add(name);
113
+ const paths = samplePaths(options.service, options.operation, name);
114
+ const exists = (yield* readIfPresent(options.workspace, paths.yaml)) !== undefined ||
115
+ (yield* readIfPresent(options.workspace, paths.xml)) !== undefined;
116
+ const body = toYamlValue(scenario.body);
117
+ const issues = validateInstance(options.catalog, input, body);
118
+ if (exists) {
119
+ written.push({ name, path: paths.yaml, issues, kept: true });
120
+ continue;
121
+ }
122
+ yield* options.workspace.write(paths.yaml, renderRequestFile({
123
+ catalog: options.catalog,
124
+ operation: options.operation,
125
+ purpose: scenario.purpose,
126
+ body,
127
+ issues
128
+ }));
129
+ written.push({ name, path: paths.yaml, issues, kept: false });
130
+ }
131
+ return written;
132
+ });
@@ -0,0 +1,200 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { elementByName, operationByName } from "./Catalog.js";
3
+ import { readIfPresent } from "./Discover.js";
4
+ import { readEnvelope, stripSecurity } from "./Envelope.js";
5
+ import { instanceFromXml } from "./Instance.js";
6
+ import { kindsFromCatalog, maskDocument, maskText, MaskingReport, mergeReports } from "./Masking.js";
7
+ import { Exchange, issueRecords, RecordedResponse, renderRequestFile, samplePaths, toSampleName, writeExchange } from "./Samples.js";
8
+ import { attribute, elements, parseXml, renderXml, textOf } from "./Xml.js";
9
+ // SoapUI / ReadyAPI projects are where bank teams keep their working
10
+ // requests. Import turns each saved request (interface calls and test
11
+ // steps) into a request file and each mock response into a recorded
12
+ // exchange. Everything passes through the masker first: a project copied
13
+ // from a shared drive often holds real customer data. Stored credentials
14
+ // (`con:credentials`, WS-S passwords) are counted and never read.
15
+ export const SoapUiNamespace = "http://eviware.com/soapui/config";
16
+ const con = (element, local) => elements(element).filter((child) => child.name.namespace === SoapUiNamespace && child.name.local === local);
17
+ const conText = (element, local) => {
18
+ const found = con(element, local)[0];
19
+ return found === undefined ? undefined : textOf(found);
20
+ };
21
+ const hasCredentials = (element) => {
22
+ const credentials = con(element, "credentials")[0];
23
+ if (credentials === undefined)
24
+ return false;
25
+ return elements(credentials).some((child) => textOf(child).trim() !== "" && /password|username|token/i.test(child.name.local));
26
+ };
27
+ /** Every saved request and mock response in a project, with its operation. */
28
+ export const findMessages = (project) => {
29
+ const messages = [];
30
+ let credentials = 0;
31
+ for (const iface of con(project, "interface")) {
32
+ for (const operation of con(iface, "operation")) {
33
+ const name = attribute(operation, "name") ?? "";
34
+ for (const call of con(operation, "call")) {
35
+ if (hasCredentials(call))
36
+ credentials++;
37
+ const envelope = conText(call, "request");
38
+ if (envelope !== undefined && envelope.trim() !== "") {
39
+ messages.push({
40
+ operation: name,
41
+ label: attribute(call, "name") ?? "call",
42
+ envelope,
43
+ kind: "request"
44
+ });
45
+ }
46
+ }
47
+ }
48
+ }
49
+ const walkSuites = (element, path) => {
50
+ for (const child of elements(element)) {
51
+ if (child.name.namespace !== SoapUiNamespace)
52
+ continue;
53
+ if (child.name.local === "testSuite" || child.name.local === "testCase") {
54
+ walkSuites(child, [...path, attribute(child, "name") ?? child.name.local]);
55
+ }
56
+ else if (child.name.local === "testStep" && attribute(child, "type") === "request") {
57
+ const config = con(child, "config")[0];
58
+ const operation = config === undefined ? undefined : conText(config, "operation");
59
+ const request = config === undefined ? undefined : con(config, "request")[0];
60
+ if (request !== undefined && hasCredentials(request))
61
+ credentials++;
62
+ const envelope = request === undefined ? undefined : conText(request, "request");
63
+ if (operation !== undefined && envelope !== undefined && envelope.trim() !== "") {
64
+ messages.push({
65
+ operation: operation.trim(),
66
+ label: [...path, attribute(child, "name") ?? "step"].join(" "),
67
+ envelope,
68
+ kind: "request"
69
+ });
70
+ }
71
+ }
72
+ }
73
+ };
74
+ walkSuites(project, []);
75
+ for (const mock of con(project, "mockService")) {
76
+ for (const operation of con(mock, "mockOperation")) {
77
+ const name = attribute(operation, "operation") ?? attribute(operation, "name") ?? "";
78
+ for (const response of con(operation, "response")) {
79
+ const envelope = conText(response, "responseContent");
80
+ if (envelope !== undefined && envelope.trim() !== "") {
81
+ messages.push({
82
+ operation: name,
83
+ label: `mock ${attribute(response, "name") ?? "response"}`,
84
+ envelope,
85
+ kind: "response"
86
+ });
87
+ }
88
+ }
89
+ }
90
+ }
91
+ return { messages, credentials };
92
+ };
93
+ export const importSoapUiProject = (options) => Effect.gen(function* () {
94
+ const { catalog, workspace, service } = options;
95
+ const project = yield* parseXml(options.project, { source: "SoapUI project" });
96
+ const { messages, credentials } = findMessages(project);
97
+ const maskOptions = {
98
+ key: options.key,
99
+ typeKinds: kindsFromCatalog(catalog),
100
+ ...(options.overrides === undefined ? {} : { overrides: options.overrides })
101
+ };
102
+ const requests = [];
103
+ const responses = [];
104
+ const skipped = [];
105
+ const reports = [];
106
+ const used = new Set();
107
+ for (const found of messages) {
108
+ // SoapUI labels are free text people type ("Conti di Mario Rossi …"):
109
+ // they reach file names and headers, so they are masked like values.
110
+ const message = { ...found, label: maskText(options.key, found.label).text };
111
+ const operation = operationByName(catalog, message.operation);
112
+ if (operation === undefined) {
113
+ skipped.push(`${message.label}: operation ${message.operation} is not in the catalog`);
114
+ continue;
115
+ }
116
+ // SoapUI keeps property expansions (${#Project#iban}) inside the XML
117
+ // text; they parse fine and are reported by validation.
118
+ const parsed = yield* parseXml(message.envelope, { source: message.label }).pipe(Effect.option);
119
+ if (parsed._tag === "None") {
120
+ skipped.push(`${message.operation} ${message.label}: not well-formed XML`);
121
+ continue;
122
+ }
123
+ const masked = maskDocument(stripSecurity(parsed.value), maskOptions);
124
+ reports.push(masked.report);
125
+ const envelope = yield* readEnvelope(masked.document).pipe(Effect.option);
126
+ const payload = envelope._tag === "Some" ? envelope.value.payload : undefined;
127
+ let base = toSampleName(`soapui-${message.label}`);
128
+ for (let suffix = 2; used.has(`${message.operation}/${base}`); suffix++) {
129
+ base = `${toSampleName(`soapui-${message.label}`).slice(0, 58)}-${suffix}`;
130
+ }
131
+ used.add(`${message.operation}/${base}`);
132
+ const paths = samplePaths(service, message.operation, base);
133
+ if (message.kind === "request") {
134
+ const input = elementByName(catalog, operation.input);
135
+ if (input === undefined || payload === undefined) {
136
+ skipped.push(`${message.operation} ${message.label}: no request body`);
137
+ continue;
138
+ }
139
+ if ((yield* readIfPresent(workspace, paths.yaml)) !== undefined ||
140
+ (yield* readIfPresent(workspace, paths.xml)) !== undefined) {
141
+ skipped.push(`${message.operation}/${base}: a request file already exists; kept it`);
142
+ continue;
143
+ }
144
+ const read = instanceFromXml(catalog, input, payload);
145
+ const placeholders = read.issues.length > 0 && JSON.stringify(read.value).includes('"?"');
146
+ const issues = placeholders
147
+ ? [
148
+ { path: "(root)", detail: "SoapUI placeholders (?) are still in this request" },
149
+ ...read.issues
150
+ ]
151
+ : read.issues;
152
+ yield* workspace.write(paths.yaml, renderRequestFile({
153
+ catalog,
154
+ operation: message.operation,
155
+ purpose: `imported from SoapUI: ${message.label}`,
156
+ body: read.value,
157
+ issues
158
+ }));
159
+ requests.push({ operation: message.operation, name: base, path: paths.yaml, issues });
160
+ }
161
+ else {
162
+ const output = operation.output === undefined ? undefined : elementByName(catalog, operation.output);
163
+ const responseIssues = envelope._tag === "Some" &&
164
+ envelope.value.fault === undefined &&
165
+ output !== undefined &&
166
+ payload !== undefined
167
+ ? instanceFromXml(catalog, output, payload).issues
168
+ : [];
169
+ const exchange = new Exchange({
170
+ version: 1,
171
+ operation: message.operation,
172
+ name: base,
173
+ purpose: `SoapUI ${message.label}`,
174
+ provenance: "soapui",
175
+ recordedAt: (options.now ?? (() => new Date()))().toISOString(),
176
+ response: new RecordedResponse({
177
+ status: 200,
178
+ headers: {},
179
+ envelope: renderXml(masked.document),
180
+ elapsedMs: 0
181
+ }),
182
+ ...(envelope._tag === "Some" && envelope.value.fault !== undefined
183
+ ? { fault: envelope.value.fault }
184
+ : {}),
185
+ requestIssues: [],
186
+ responseIssues: issueRecords(responseIssues),
187
+ masking: masked.report
188
+ });
189
+ const path = yield* writeExchange(workspace, service, exchange);
190
+ responses.push({ operation: message.operation, name: base, path });
191
+ }
192
+ }
193
+ return {
194
+ requests,
195
+ responses,
196
+ skipped,
197
+ credentialsIgnored: credentials,
198
+ masking: reports.length === 0 ? new MaskingReport({ entries: [], kept: [] }) : mergeReports(reports)
199
+ };
200
+ });
@@ -0,0 +1,34 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import * as Effect from "effect/Effect";
4
+ import { Soap11Namespace } from "./Envelope.js";
5
+ // A backend stand-in: answers each call with `<directory>/<operation>.xml`,
6
+ // the operation found from the SOAPAction (SOAP 1.1 header or the SOAP 1.2
7
+ // content-type `action`). For rehearsals without a reachable service
8
+ // (LLM4TS_SOAP_STUB) and for the ACE tests' recorded backend.
9
+ export const soapActionOf = (request) => {
10
+ const header = request.headers["soapaction"];
11
+ if (header !== undefined)
12
+ return header.replace(/^"|"$/g, "");
13
+ return /action="([^"]*)"/.exec(request.headers["content-type"] ?? "")?.[1] ?? "";
14
+ };
15
+ const stubFault = (reason) => ({
16
+ status: 500,
17
+ headers: { "content-type": "text/xml; charset=utf-8" },
18
+ body: new TextEncoder().encode(`<s:Envelope xmlns:s="${Soap11Namespace}"><s:Body><s:Fault><faultcode>s:Server</faultcode><faultstring>${reason}</faultstring></s:Fault></s:Body></s:Envelope>`),
19
+ elapsedMs: 1
20
+ });
21
+ export const makeDirectoryStubTransport = (directory, catalog) => ({
22
+ send: (request) => {
23
+ const action = soapActionOf(request);
24
+ const operation = catalog.operations.find((candidate) => candidate.soapAction === action);
25
+ if (operation === undefined)
26
+ return Effect.succeed(stubFault(`stub: no operation for action ${action}`));
27
+ return Effect.tryPromise(() => readFile(join(directory, `${operation.name}.xml`))).pipe(Effect.map((bytes) => ({
28
+ status: 200,
29
+ headers: { "content-type": "text/xml; charset=utf-8" },
30
+ body: new Uint8Array(bytes),
31
+ elapsedMs: 1
32
+ })), Effect.orElseSucceed(() => stubFault(`stub: no ${operation.name}.xml`)));
33
+ }
34
+ });
@@ -0,0 +1,167 @@
1
+ import * as http from "node:http";
2
+ import * as https from "node:https";
3
+ import * as Context from "effect/Context";
4
+ import * as Duration from "effect/Duration";
5
+ import * as Effect from "effect/Effect";
6
+ import * as Redacted from "effect/Redacted";
7
+ import * as Ref from "effect/Ref";
8
+ import * as Schema from "effect/Schema";
9
+ import { DocumentLoadError, makeFileDocumentLoader } from "./Wsdl.js";
10
+ export class TransportError extends Schema.TaggedError()("TransportError", {
11
+ url: Schema.String,
12
+ reason: Schema.Literals(["scheme", "network", "tls", "timeout", "aborted"]),
13
+ detail: Schema.String
14
+ }) {
15
+ get message() {
16
+ return `${this.url}: ${this.reason}: ${this.detail}`;
17
+ }
18
+ }
19
+ export class SoapTransport extends Context.Service()("@llm4ts/kits/soap-ace/SoapTransport") {
20
+ }
21
+ /** Strip query string and credentials from a URL before it lands in an error or a file. */
22
+ export const displayUrl = (raw) => {
23
+ try {
24
+ const url = new URL(raw);
25
+ return `${url.protocol}//${url.host}${url.pathname}`;
26
+ }
27
+ catch {
28
+ return "(invalid url)";
29
+ }
30
+ };
31
+ const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
32
+ ? cause.code
33
+ : "request failed";
34
+ const tlsCodes = /CERT|SSL|TLS|SELF_SIGNED|UNABLE_TO|DEPTH_ZERO|ERR_OSSL/;
35
+ const flatHeaders = (headers) => Object.fromEntries(Object.entries(headers).flatMap(([name, value]) => value === undefined ? [] : [[name, Array.isArray(value) ? value.join(", ") : value]]));
36
+ const bufferOf = (secret) => secret === undefined ? undefined : Buffer.from(Redacted.value(secret));
37
+ export const makeNodeSoapTransport = () => ({
38
+ send: (request) => {
39
+ const shown = displayUrl(request.url);
40
+ let url;
41
+ try {
42
+ url = new URL(request.url);
43
+ }
44
+ catch {
45
+ return Effect.fail(new TransportError({ url: shown, reason: "scheme", detail: "invalid URL" }));
46
+ }
47
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
48
+ return Effect.fail(new TransportError({
49
+ url: shown,
50
+ reason: "scheme",
51
+ detail: `${url.protocol} is not http(s)`
52
+ }));
53
+ }
54
+ if (url.protocol === "http:" && request.tls !== undefined) {
55
+ return Effect.fail(new TransportError({
56
+ url: shown,
57
+ reason: "scheme",
58
+ detail: "client TLS needs an https URL"
59
+ }));
60
+ }
61
+ const headers = { ...request.headers };
62
+ for (const [name, value] of request.secretHeaders)
63
+ headers[name] = Redacted.value(value);
64
+ const body = request.body === undefined ? undefined : Buffer.from(Redacted.value(request.body), "utf8");
65
+ if (body !== undefined)
66
+ headers["content-length"] = String(body.length);
67
+ return Effect.tryPromise({
68
+ try: (signal) => new Promise((resolve, reject) => {
69
+ const started = performance.now();
70
+ const tls = request.tls;
71
+ const options = {
72
+ method: request.method,
73
+ headers,
74
+ signal,
75
+ ...(tls === undefined
76
+ ? {}
77
+ : {
78
+ cert: bufferOf(tls.cert),
79
+ key: bufferOf(tls.key),
80
+ pfx: bufferOf(tls.pfx),
81
+ ca: bufferOf(tls.ca),
82
+ passphrase: tls.passphrase === undefined ? undefined : Redacted.value(tls.passphrase)
83
+ })
84
+ };
85
+ const handler = (response) => {
86
+ const chunks = [];
87
+ response.on("data", (chunk) => chunks.push(chunk));
88
+ response.on("error", reject);
89
+ response.on("end", () => resolve({
90
+ status: response.statusCode ?? 0,
91
+ headers: flatHeaders(response.headers),
92
+ body: new Uint8Array(Buffer.concat(chunks)),
93
+ elapsedMs: Math.round(performance.now() - started)
94
+ }));
95
+ };
96
+ const outgoing = url.protocol === "https:"
97
+ ? https.request(url, options, handler)
98
+ : http.request(url, options, handler);
99
+ outgoing.on("error", reject);
100
+ if (body !== undefined)
101
+ outgoing.write(body);
102
+ outgoing.end();
103
+ }),
104
+ catch: (cause) => {
105
+ const code = errorCode(cause);
106
+ return new TransportError({
107
+ url: shown,
108
+ reason: code === "ABORT_ERR" ? "aborted" : tlsCodes.test(code) ? "tls" : "network",
109
+ detail: code
110
+ });
111
+ }
112
+ }).pipe(Effect.timeoutOrElse({
113
+ duration: request.timeout,
114
+ orElse: () => Effect.fail(new TransportError({
115
+ url: shown,
116
+ reason: "timeout",
117
+ detail: `no response within ${Duration.format(request.timeout)}`
118
+ }))
119
+ }));
120
+ }
121
+ });
122
+ /**
123
+ * A scripted transport for tests: each request goes to `respond`, and every
124
+ * request is recorded (secrets still redacted) for assertions.
125
+ */
126
+ export const makeFakeSoapTransport = (respond) => Effect.map(Ref.make([]), (log) => ({
127
+ transport: {
128
+ send: (request) => Effect.andThen(Ref.update(log, (all) => [...all, request]), respond(request))
129
+ },
130
+ requests: Ref.get(log)
131
+ }));
132
+ export const xmlResponse = (xml, status = 200) => ({
133
+ status,
134
+ headers: { "content-type": "text/xml; charset=utf-8" },
135
+ body: new TextEncoder().encode(xml),
136
+ elapsedMs: 5
137
+ });
138
+ const isUrl = (location) => /^https?:\/\//i.test(location);
139
+ /**
140
+ * The document loader for discovery: local paths from disk, http(s) URLs
141
+ * through the transport with the profile's fetch-side auth and TLS.
142
+ */
143
+ export const makeTransportDocumentLoader = (transport, side, timeout = Duration.seconds(30)) => {
144
+ const files = makeFileDocumentLoader();
145
+ return {
146
+ load: (location) => !isUrl(location)
147
+ ? files.load(location)
148
+ : transport
149
+ .send({
150
+ method: "GET",
151
+ url: location,
152
+ headers: { accept: "text/xml, application/xml, */*" },
153
+ secretHeaders: side.headers,
154
+ ...(side.tls === undefined ? {} : { tls: side.tls }),
155
+ timeout
156
+ })
157
+ .pipe(Effect.mapError((error) => new DocumentLoadError({
158
+ location: displayUrl(location),
159
+ detail: `${error.reason}: ${error.detail}`
160
+ })), Effect.flatMap((response) => response.status >= 200 && response.status < 300
161
+ ? Effect.succeed(response.body)
162
+ : Effect.fail(new DocumentLoadError({
163
+ location: displayUrl(location),
164
+ detail: `HTTP ${response.status}`
165
+ }))))
166
+ };
167
+ };