@llm4ts/shell 2.11.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.
- package/flows/epic-stories.js +25 -5
- package/flows/lib/epic-stories.js +76 -8
- package/kits/soap-ace/README.md +313 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +102 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/design/api-design.md +620 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaConti.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaMovimenti.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/confermaBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/dettaglioConto.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/inserisciBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/revocaBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/soapui/DemoBank-soapui-project.xml +39 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/DemoBank.wsdl +186 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/LegacyRpc.wsdl +29 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/banking.xsd +10 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/bonifici.xsd +107 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/common.xsd +93 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/conti.xsd +71 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/movimenti.xsd +55 -0
- package/kits/soap-ace/flows/lib/soap/Analysis.js +508 -0
- package/kits/soap-ace/flows/lib/soap/Auth.js +179 -0
- package/kits/soap-ace/flows/lib/soap/Call.js +186 -0
- package/kits/soap-ace/flows/lib/soap/CallPolicy.js +50 -0
- package/kits/soap-ace/flows/lib/soap/Catalog.js +157 -0
- package/kits/soap-ace/flows/lib/soap/Classification.js +254 -0
- package/kits/soap-ace/flows/lib/soap/Design.js +497 -0
- package/kits/soap-ace/flows/lib/soap/DesignProposal.js +204 -0
- package/kits/soap-ace/flows/lib/soap/Discover.js +139 -0
- package/kits/soap-ace/flows/lib/soap/Envelope.js +132 -0
- package/kits/soap-ace/flows/lib/soap/Epic.js +239 -0
- package/kits/soap-ace/flows/lib/soap/Instance.js +675 -0
- package/kits/soap-ace/flows/lib/soap/Mapping.js +130 -0
- package/kits/soap-ace/flows/lib/soap/Masking.js +508 -0
- package/kits/soap-ace/flows/lib/soap/OpenApi.js +288 -0
- package/kits/soap-ace/flows/lib/soap/Samples.js +287 -0
- package/kits/soap-ace/flows/lib/soap/Scenarios.js +132 -0
- package/kits/soap-ace/flows/lib/soap/SoapUiImport.js +200 -0
- package/kits/soap-ace/flows/lib/soap/Stub.js +34 -0
- package/kits/soap-ace/flows/lib/soap/Transport.js +167 -0
- package/kits/soap-ace/flows/lib/soap/Wsdl.js +533 -0
- package/kits/soap-ace/flows/lib/soap/Xml.js +453 -0
- package/kits/soap-ace/flows/lib/soap/Xsd.js +471 -0
- package/kits/soap-ace/flows/lib/soap/Yaml.js +309 -0
- package/kits/soap-ace/flows/soap-design.js +167 -0
- package/kits/soap-ace/flows/soap-discover.js +85 -0
- package/kits/soap-ace/flows/soap-epic.js +204 -0
- package/kits/soap-ace/flows/soap-sample.js +265 -0
- package/kits/soap-ace/packs/ace12-rest/api-style.md +63 -0
- package/kits/soap-ace/packs/ace12-rest/pack.md +18 -0
- package/kits/soap-ace/packs/ace12-rest/prompts/implement.md +8 -0
- package/kits/soap-ace/packs/ace12-rest/reviewers/contract.md +5 -0
- package/kits/soap-ace/patterns/PAT-ACE-001-esito-to-problem.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-002-fault-to-problem.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-003-amounts-and-enums.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-004-pagination.md +7 -0
- package/kits/soap-ace/patterns/PAT-ACE-005-nil-and-absence.md +7 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/CONTRIBUTING.md +56 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/README.md +16 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates-container.sh +8 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates.sh +41 -0
- package/package.json +4 -4
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Redacted from "effect/Redacted";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
// The auth profile, `.llm4ts/soap/<service>/auth.json` (gitignored). It holds
|
|
6
|
+
// references only — `env:NAME` or `file:path` — never a secret value; a
|
|
7
|
+
// literal where a secret belongs is a decode error. References are resolved
|
|
8
|
+
// at call time into `Redacted` values that live inside the transport; no
|
|
9
|
+
// secret reaches arguments, logs, traces, persisted files, or error
|
|
10
|
+
// messages (errors name the reference, never its value).
|
|
11
|
+
export const Environment = Schema.Literals(["dev", "test", "uat"]);
|
|
12
|
+
/** `env:NAME` or `file:path`. */
|
|
13
|
+
export const SecretRef = Schema.String.check(Schema.isPattern(/^(env:[A-Za-z_][A-Za-z0-9_]*|file:.+)$/, {
|
|
14
|
+
message: "must be a reference, env:NAME or file:path — never the secret itself"
|
|
15
|
+
}));
|
|
16
|
+
/** A username may be written literally or referenced. */
|
|
17
|
+
export const Principal = Schema.String;
|
|
18
|
+
export class BasicAuth extends Schema.Class("BasicAuth")({
|
|
19
|
+
scheme: Schema.Literal("basic"),
|
|
20
|
+
user: Principal,
|
|
21
|
+
password: SecretRef
|
|
22
|
+
}) {
|
|
23
|
+
}
|
|
24
|
+
export class BearerAuth extends Schema.Class("BearerAuth")({
|
|
25
|
+
scheme: Schema.Literal("bearer"),
|
|
26
|
+
token: SecretRef
|
|
27
|
+
}) {
|
|
28
|
+
}
|
|
29
|
+
export class NoAuth extends Schema.Class("NoAuth")({
|
|
30
|
+
scheme: Schema.Literal("none")
|
|
31
|
+
}) {
|
|
32
|
+
}
|
|
33
|
+
export const HttpAuth = Schema.Union([NoAuth, BasicAuth, BearerAuth]);
|
|
34
|
+
/** Client TLS: PEM cert+key, or a PKCS#12 bundle; an optional CA bundle. */
|
|
35
|
+
export class TlsConfig extends Schema.Class("TlsConfig")({
|
|
36
|
+
cert: Schema.optionalKey(SecretRef),
|
|
37
|
+
key: Schema.optionalKey(SecretRef),
|
|
38
|
+
pfx: Schema.optionalKey(SecretRef),
|
|
39
|
+
passphrase: Schema.optionalKey(SecretRef),
|
|
40
|
+
ca: Schema.optionalKey(SecretRef)
|
|
41
|
+
}) {
|
|
42
|
+
}
|
|
43
|
+
export class HttpSide extends Schema.Class("HttpSide")({
|
|
44
|
+
auth: Schema.optionalKey(HttpAuth),
|
|
45
|
+
tls: Schema.optionalKey(TlsConfig)
|
|
46
|
+
}) {
|
|
47
|
+
}
|
|
48
|
+
export class UsernameToken extends Schema.Class("UsernameToken")({
|
|
49
|
+
user: Principal,
|
|
50
|
+
password: SecretRef,
|
|
51
|
+
passwordType: Schema.Literals(["text", "digest"])
|
|
52
|
+
}) {
|
|
53
|
+
}
|
|
54
|
+
export const MutatingPolicy = Schema.Literals(["confirm", "flag", "deny"]);
|
|
55
|
+
export class MutatingPolicies extends Schema.Class("MutatingPolicies")({
|
|
56
|
+
dev: Schema.optionalKey(MutatingPolicy),
|
|
57
|
+
test: Schema.optionalKey(MutatingPolicy),
|
|
58
|
+
uat: Schema.optionalKey(MutatingPolicy)
|
|
59
|
+
}) {
|
|
60
|
+
}
|
|
61
|
+
export class AuthProfile extends Schema.Class("AuthProfile")({
|
|
62
|
+
environment: Environment,
|
|
63
|
+
/** Overrides the WSDL's address for calls (the WSDL often names another environment). */
|
|
64
|
+
endpoint: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^https?:\/\/[^/?#@]+(?:[/?#][^@]*)?$/i, {
|
|
65
|
+
message: "an http(s) URL without credentials; secrets go in references"
|
|
66
|
+
}))),
|
|
67
|
+
/** Fetching the WSDL and its schemas. */
|
|
68
|
+
fetch: Schema.optionalKey(HttpSide),
|
|
69
|
+
/** Calling operations. */
|
|
70
|
+
call: Schema.optionalKey(HttpSide),
|
|
71
|
+
wsSecurity: Schema.optionalKey(UsernameToken),
|
|
72
|
+
mutating: Schema.optionalKey(MutatingPolicies),
|
|
73
|
+
timeoutSeconds: Schema.optionalKey(Schema.Int)
|
|
74
|
+
}) {
|
|
75
|
+
}
|
|
76
|
+
export const defaultMutatingPolicy = (environment) => environment === "dev" ? "confirm" : "flag";
|
|
77
|
+
export const mutatingPolicy = (profile) => profile.mutating?.[profile.environment] ?? defaultMutatingPolicy(profile.environment);
|
|
78
|
+
export class AuthProfileError extends Schema.TaggedError()("AuthProfileError", {
|
|
79
|
+
/** The reference or field involved; never a secret value. */
|
|
80
|
+
subject: Schema.String,
|
|
81
|
+
detail: Schema.String
|
|
82
|
+
}) {
|
|
83
|
+
get message() {
|
|
84
|
+
return `auth profile: ${this.subject}: ${this.detail}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export const decodeAuthProfile = (text) => Schema.decodeUnknownEffect(Schema.fromJsonString(AuthProfile))(text).pipe(Effect.mapError((error) =>
|
|
88
|
+
// Schema issues quote the offending input; a mistyped secret could be
|
|
89
|
+
// in it, so only the paths of the issue are kept.
|
|
90
|
+
new AuthProfileError({
|
|
91
|
+
subject: "auth.json",
|
|
92
|
+
detail: `invalid profile (${issuePaths(error.message)})`
|
|
93
|
+
})));
|
|
94
|
+
/** Keep only the `at ["a"]["b"]` locations of a schema message, dropping any quoted value. */
|
|
95
|
+
const issuePaths = (message) => {
|
|
96
|
+
const paths = [...message.matchAll(/at ((?:\[[^\]]*\])+)/g)].map((match) => [...(match[1] ?? "").matchAll(/\["?([^"\]]*)"?\]/g)].map((part) => part[1] ?? "").join("."));
|
|
97
|
+
return paths.length === 0
|
|
98
|
+
? "see the soap-ace README for the profile shape"
|
|
99
|
+
: `at ${[...new Set(paths)].join(", ")}; secrets must be env:NAME or file:path references`;
|
|
100
|
+
};
|
|
101
|
+
export const resolveSecret = (source, reference) => {
|
|
102
|
+
if (reference.startsWith("env:")) {
|
|
103
|
+
const name = reference.slice(4);
|
|
104
|
+
const value = source.environment[name];
|
|
105
|
+
return value === undefined || value === ""
|
|
106
|
+
? Effect.fail(new AuthProfileError({ subject: reference, detail: "variable is not set" }))
|
|
107
|
+
: Effect.succeed(Redacted.make(new TextEncoder().encode(value)));
|
|
108
|
+
}
|
|
109
|
+
if (reference.startsWith("file:")) {
|
|
110
|
+
return Effect.map(source.readFile(reference.slice(5)), (bytes) => Redacted.make(bytes));
|
|
111
|
+
}
|
|
112
|
+
return Effect.fail(new AuthProfileError({ subject: "reference", detail: "not env: or file:" }));
|
|
113
|
+
};
|
|
114
|
+
export const resolveText = (source, reference) => Effect.map(resolveSecret(source, reference), (secret) => Redacted.make(new TextDecoder().decode(Redacted.value(secret)).replace(/\r?\n$/, "")));
|
|
115
|
+
/** A principal: resolved when it is a reference, else the literal. */
|
|
116
|
+
export const resolvePrincipal = (source, value) => value.startsWith("env:") || value.startsWith("file:")
|
|
117
|
+
? Effect.map(resolveText(source, value), Redacted.value)
|
|
118
|
+
: Effect.succeed(value);
|
|
119
|
+
const optionalSecret = (source, name, reference) => reference === undefined
|
|
120
|
+
? Effect.succeed({})
|
|
121
|
+
: Effect.map(resolveSecret(source, reference), (value) => ({ [name]: value }));
|
|
122
|
+
export const resolveTls = (source, tls) => tls === undefined
|
|
123
|
+
? Effect.succeed(undefined)
|
|
124
|
+
: Effect.gen(function* () {
|
|
125
|
+
if (tls.pfx !== undefined && (tls.cert !== undefined || tls.key !== undefined)) {
|
|
126
|
+
return yield* new AuthProfileError({
|
|
127
|
+
subject: "tls",
|
|
128
|
+
detail: "use either pfx or cert+key, not both"
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if ((tls.cert === undefined) !== (tls.key === undefined)) {
|
|
132
|
+
return yield* new AuthProfileError({
|
|
133
|
+
subject: "tls",
|
|
134
|
+
detail: "cert and key go together"
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const cert = yield* optionalSecret(source, "cert", tls.cert);
|
|
138
|
+
const key = yield* optionalSecret(source, "key", tls.key);
|
|
139
|
+
const pfx = yield* optionalSecret(source, "pfx", tls.pfx);
|
|
140
|
+
const ca = yield* optionalSecret(source, "ca", tls.ca);
|
|
141
|
+
const passphrase = tls.passphrase === undefined
|
|
142
|
+
? {}
|
|
143
|
+
: { passphrase: yield* resolveText(source, tls.passphrase) };
|
|
144
|
+
return { ...cert, ...key, ...pfx, ...ca, ...passphrase };
|
|
145
|
+
});
|
|
146
|
+
const basicHeader = (user, password) => Redacted.make(`Basic ${Buffer.from(`${user}:${Redacted.value(password)}`, "utf8").toString("base64")}`);
|
|
147
|
+
export const resolveSide = (source, side) => Effect.gen(function* () {
|
|
148
|
+
const auth = side?.auth;
|
|
149
|
+
const tls = yield* resolveTls(source, side?.tls);
|
|
150
|
+
if (auth === undefined || auth.scheme === "none")
|
|
151
|
+
return { headers: [], tls };
|
|
152
|
+
if (auth.scheme === "basic") {
|
|
153
|
+
const user = yield* resolvePrincipal(source, auth.user);
|
|
154
|
+
const password = yield* resolveText(source, auth.password);
|
|
155
|
+
return { headers: [["authorization", basicHeader(user, password)]], tls };
|
|
156
|
+
}
|
|
157
|
+
const token = yield* resolveText(source, auth.token);
|
|
158
|
+
return {
|
|
159
|
+
headers: [["authorization", Redacted.make(`Bearer ${Redacted.value(token)}`)]],
|
|
160
|
+
tls
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
export const resolveUsernameToken = (source, token) => token === undefined
|
|
164
|
+
? Effect.succeed(undefined)
|
|
165
|
+
: Effect.gen(function* () {
|
|
166
|
+
return {
|
|
167
|
+
user: yield* resolvePrincipal(source, token.user),
|
|
168
|
+
password: yield* resolveText(source, token.password),
|
|
169
|
+
passwordType: token.passwordType
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
/** Secrets from the process environment and local files. */
|
|
173
|
+
export const nodeSecretSource = (environment = process.env) => ({
|
|
174
|
+
environment,
|
|
175
|
+
readFile: (path) => Effect.tryPromise({
|
|
176
|
+
try: async () => new Uint8Array(await readFile(path)),
|
|
177
|
+
catch: () => new AuthProfileError({ subject: `file:${path}`, detail: "not readable" })
|
|
178
|
+
})
|
|
179
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as Duration from "effect/Duration";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Redacted from "effect/Redacted";
|
|
5
|
+
import * as Schema from "effect/Schema";
|
|
6
|
+
import { resolveSide, resolveUsernameToken } from "./Auth.js";
|
|
7
|
+
import { checkCall } from "./CallPolicy.js";
|
|
8
|
+
import { elementByName, operationByName } from "./Catalog.js";
|
|
9
|
+
import { effectiveClass } from "./Classification.js";
|
|
10
|
+
import { buildEnvelope, readEnvelope, safeHeaders, soapHeaders, stripSecurity, usernameTokenHeader } from "./Envelope.js";
|
|
11
|
+
import { instanceFromXml, instanceToXml, validateInstance } from "./Instance.js";
|
|
12
|
+
import { kindsFromCatalog, maskDocument, MaskingReport, mergeReports } from "./Masking.js";
|
|
13
|
+
import { Exchange, issueRecords, loadMaskingKey, loadMaskingOverrides, readRequestFile, RecordedMessage, RecordedResponse, samplePaths, writeExchange } from "./Samples.js";
|
|
14
|
+
import { displayUrl } from "./Transport.js";
|
|
15
|
+
import { parseXml, parseXmlBytes, renderXml } from "./Xml.js";
|
|
16
|
+
// One call, end to end: read and validate the request file, check the call
|
|
17
|
+
// policy, render the envelope (WS-Security header when configured), send it
|
|
18
|
+
// with the profile's call-side auth and TLS, read the response against the
|
|
19
|
+
// output element's schema, mask both messages, and persist the exchange.
|
|
20
|
+
// A request that does not validate is never sent. A response that breaks
|
|
21
|
+
// its schema is recorded with the issues; that is a finding for the design.
|
|
22
|
+
export class RequestInvalid extends Schema.TaggedError()("RequestInvalid", {
|
|
23
|
+
operation: Schema.String,
|
|
24
|
+
name: Schema.String,
|
|
25
|
+
issues: Schema.Array(Schema.Struct({ path: Schema.String, detail: Schema.String }))
|
|
26
|
+
}) {
|
|
27
|
+
get message() {
|
|
28
|
+
return `${this.operation}/${this.name} not sent; the request does not validate:\n${this.issues
|
|
29
|
+
.map((issue) => ` ${issue.path}: ${issue.detail}`)
|
|
30
|
+
.join("\n")}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class CallSetupError extends Schema.TaggedError()("CallSetupError", {
|
|
34
|
+
operation: Schema.String,
|
|
35
|
+
detail: Schema.String
|
|
36
|
+
}) {
|
|
37
|
+
get message() {
|
|
38
|
+
return `${this.operation}: ${this.detail}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The endpoint for an operation: the profile's override, else the WSDL port of its binding. */
|
|
42
|
+
export const endpointFor = (catalog, operation, profile) => profile.endpoint ??
|
|
43
|
+
catalog.endpoints.find((endpoint) => endpoint.binding === operation.binding)?.address ??
|
|
44
|
+
catalog.endpoints.find((endpoint) => endpoint.soapVersion === operation.soapVersion)?.address;
|
|
45
|
+
const maskEnvelope = (root, key, options) => {
|
|
46
|
+
const masked = maskDocument(stripSecurity(root), { key, ...options });
|
|
47
|
+
return { document: masked.document, xml: renderXml(masked.document), report: masked.report };
|
|
48
|
+
};
|
|
49
|
+
export const callOperation = (options) => Effect.gen(function* () {
|
|
50
|
+
const { catalog, workspace, service, name } = options;
|
|
51
|
+
const operation = operationByName(catalog, options.operation);
|
|
52
|
+
if (operation === undefined) {
|
|
53
|
+
return yield* new CallSetupError({
|
|
54
|
+
operation: options.operation,
|
|
55
|
+
detail: "not an operation of this service"
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const input = elementByName(catalog, operation.input);
|
|
59
|
+
if (input === undefined) {
|
|
60
|
+
return yield* new CallSetupError({
|
|
61
|
+
operation: operation.name,
|
|
62
|
+
detail: "request element missing from the catalog"
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const profile = options.profile;
|
|
66
|
+
if (profile === undefined) {
|
|
67
|
+
return yield* new CallSetupError({
|
|
68
|
+
operation: operation.name,
|
|
69
|
+
detail: `no auth profile; write ${`.llm4ts/soap/${service}/auth.json`} with at least {"environment": "dev"}`
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const request = yield* readRequestFile(workspace, catalog, service, operation.name, name);
|
|
73
|
+
const issues = [
|
|
74
|
+
...request.readIssues,
|
|
75
|
+
...validateInstance(catalog, input, request.body)
|
|
76
|
+
];
|
|
77
|
+
if (issues.length > 0) {
|
|
78
|
+
return yield* new RequestInvalid({ operation: operation.name, name, issues });
|
|
79
|
+
}
|
|
80
|
+
const permission = yield* checkCall({
|
|
81
|
+
operation: operation.name,
|
|
82
|
+
operationClass: effectiveClass(options.operations, operation.name),
|
|
83
|
+
profile,
|
|
84
|
+
allowMutating: options.allowMutating,
|
|
85
|
+
confirm: options.confirm
|
|
86
|
+
});
|
|
87
|
+
const address = endpointFor(catalog, operation, profile);
|
|
88
|
+
if (address === undefined) {
|
|
89
|
+
return yield* new CallSetupError({
|
|
90
|
+
operation: operation.name,
|
|
91
|
+
detail: 'no endpoint: the WSDL declares none for this binding; set "endpoint" in auth.json'
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const side = yield* resolveSide(options.secrets, profile.call);
|
|
95
|
+
const token = yield* resolveUsernameToken(options.secrets, profile.wsSecurity);
|
|
96
|
+
const now = options.now ?? (() => new Date());
|
|
97
|
+
const random = options.random ?? ((size) => new Uint8Array(randomBytes(size)));
|
|
98
|
+
const header = token === undefined
|
|
99
|
+
? undefined
|
|
100
|
+
: usernameTokenHeader(token, { nonce: random(16), created: now().toISOString() });
|
|
101
|
+
const bodyXml = instanceToXml(catalog, input, request.body);
|
|
102
|
+
const envelope = buildEnvelope(operation.soapVersion, bodyXml, header);
|
|
103
|
+
const headers = soapHeaders(operation.soapVersion, operation.soapAction);
|
|
104
|
+
const response = yield* options.transport.send({
|
|
105
|
+
method: "POST",
|
|
106
|
+
url: address,
|
|
107
|
+
headers,
|
|
108
|
+
secretHeaders: side.headers,
|
|
109
|
+
body: envelope,
|
|
110
|
+
...(side.tls === undefined ? {} : { tls: side.tls }),
|
|
111
|
+
timeout: Duration.seconds(profile.timeoutSeconds ?? 60)
|
|
112
|
+
});
|
|
113
|
+
const key = yield* loadMaskingKey(workspace, service, random);
|
|
114
|
+
const overrides = yield* loadMaskingOverrides(workspace, service);
|
|
115
|
+
const maskOptions = {
|
|
116
|
+
typeKinds: kindsFromCatalog(catalog),
|
|
117
|
+
...(overrides === undefined ? {} : { overrides })
|
|
118
|
+
};
|
|
119
|
+
// The request is re-parsed from what was sent, so the recorded envelope
|
|
120
|
+
// is exactly the message on the wire minus its security header.
|
|
121
|
+
const sent = yield* parseXml(Redacted.value(envelope));
|
|
122
|
+
const maskedRequest = maskEnvelope(sent, key, maskOptions);
|
|
123
|
+
const responseIssues = [];
|
|
124
|
+
const parsedResponse = yield* parseXmlBytes(response.body).pipe(Effect.option);
|
|
125
|
+
let maskedResponse = {
|
|
126
|
+
xml: "",
|
|
127
|
+
report: new MaskingReport({ entries: [], kept: [] })
|
|
128
|
+
};
|
|
129
|
+
// Everything recorded about the response (fault text, schema findings,
|
|
130
|
+
// which quote values) is read from the masked, security-stripped tree:
|
|
131
|
+
// nothing unmasked reaches the exchange.
|
|
132
|
+
let fault;
|
|
133
|
+
if (parsedResponse._tag === "None") {
|
|
134
|
+
responseIssues.push({
|
|
135
|
+
path: "(response)",
|
|
136
|
+
detail: `HTTP ${response.status} with a body that is not XML (${response.body.length} bytes)`
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const masked = maskEnvelope(parsedResponse.value, key, maskOptions);
|
|
141
|
+
maskedResponse = masked;
|
|
142
|
+
const read = yield* readEnvelope(masked.document).pipe(Effect.option);
|
|
143
|
+
if (read._tag === "None") {
|
|
144
|
+
responseIssues.push({ path: "(response)", detail: "XML that is not a SOAP envelope" });
|
|
145
|
+
}
|
|
146
|
+
else if (read.value.fault !== undefined) {
|
|
147
|
+
fault = read.value.fault;
|
|
148
|
+
}
|
|
149
|
+
else if (operation.output !== undefined) {
|
|
150
|
+
const output = elementByName(catalog, operation.output);
|
|
151
|
+
const payload = read.value.payload;
|
|
152
|
+
if (output !== undefined && payload !== undefined) {
|
|
153
|
+
responseIssues.push(...instanceFromXml(catalog, output, payload).issues);
|
|
154
|
+
}
|
|
155
|
+
else if (payload === undefined) {
|
|
156
|
+
responseIssues.push({ path: "(response)", detail: "empty Body" });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (options.keepRaw === true) {
|
|
160
|
+
yield* workspace.write(samplePaths(service, operation.name, name).raw, renderXml(stripSecurity(parsedResponse.value)));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const exchange = new Exchange({
|
|
164
|
+
version: 1,
|
|
165
|
+
operation: operation.name,
|
|
166
|
+
name,
|
|
167
|
+
purpose: request.purpose,
|
|
168
|
+
provenance: "call",
|
|
169
|
+
recordedAt: now().toISOString(),
|
|
170
|
+
environment: profile.environment,
|
|
171
|
+
endpoint: displayUrl(address),
|
|
172
|
+
request: new RecordedMessage({ headers: safeHeaders(headers), envelope: maskedRequest.xml }),
|
|
173
|
+
response: new RecordedResponse({
|
|
174
|
+
status: response.status,
|
|
175
|
+
headers: safeHeaders(response.headers),
|
|
176
|
+
envelope: maskedResponse.xml,
|
|
177
|
+
elapsedMs: response.elapsedMs
|
|
178
|
+
}),
|
|
179
|
+
...(fault === undefined ? {} : { fault }),
|
|
180
|
+
requestIssues: [],
|
|
181
|
+
responseIssues: issueRecords(responseIssues),
|
|
182
|
+
masking: mergeReports([maskedRequest.report, maskedResponse.report])
|
|
183
|
+
});
|
|
184
|
+
const path = yield* writeExchange(workspace, service, exchange);
|
|
185
|
+
return { exchange, path, permission };
|
|
186
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { mutatingPolicy } from "./Auth.js";
|
|
4
|
+
// Whether one operation may be called now. Non-production environments
|
|
5
|
+
// still share data and reach other teams' downstream test systems, so the
|
|
6
|
+
// guard stays on everywhere:
|
|
7
|
+
//
|
|
8
|
+
// unclassified → refused (confirm operations.md first)
|
|
9
|
+
// read → allowed
|
|
10
|
+
// mutating → the environment's policy: `confirm` (default in dev)
|
|
11
|
+
// asks interactively, `flag` (default in test and uat)
|
|
12
|
+
// needs `--allow-mutating <operation>` naming exactly this
|
|
13
|
+
// operation, `deny` refuses. Profiles may override per
|
|
14
|
+
// environment.
|
|
15
|
+
export class CallRefused extends Schema.TaggedError()("CallRefused", {
|
|
16
|
+
operation: Schema.String,
|
|
17
|
+
detail: Schema.String
|
|
18
|
+
}) {
|
|
19
|
+
get message() {
|
|
20
|
+
return `${this.operation} not called: ${this.detail}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const refuse = (operation, detail) => Effect.fail(new CallRefused({ operation, detail }));
|
|
24
|
+
export const checkCall = (request) => {
|
|
25
|
+
const { operation, operationClass, profile } = request;
|
|
26
|
+
if (request.allowMutating !== undefined && /[*?,\s]/.test(request.allowMutating)) {
|
|
27
|
+
return refuse(operation, "--allow-mutating names exactly one operation; no wildcards or lists");
|
|
28
|
+
}
|
|
29
|
+
if (operationClass === "unclassified") {
|
|
30
|
+
return refuse(operation, "the operation is unclassified; classify it in operations.md and set Status: confirmed");
|
|
31
|
+
}
|
|
32
|
+
if (operationClass === "read")
|
|
33
|
+
return Effect.succeed({ operation, operationClass });
|
|
34
|
+
const policy = mutatingPolicy(profile);
|
|
35
|
+
const flagged = request.allowMutating === operation;
|
|
36
|
+
switch (policy) {
|
|
37
|
+
case "deny":
|
|
38
|
+
return refuse(operation, `mutating calls are denied in ${profile.environment}`);
|
|
39
|
+
case "flag":
|
|
40
|
+
return flagged
|
|
41
|
+
? Effect.succeed({ operation, operationClass, allowedBy: "flag" })
|
|
42
|
+
: refuse(operation, `mutating calls in ${profile.environment} need --allow-mutating ${operation}`);
|
|
43
|
+
case "confirm":
|
|
44
|
+
if (flagged)
|
|
45
|
+
return Effect.succeed({ operation, operationClass, allowedBy: "flag" });
|
|
46
|
+
return Effect.flatMap(request.confirm(`${operation} changes state in ${profile.environment}. Call it now? [y/N]`), (confirmed) => confirmed
|
|
47
|
+
? Effect.succeed({ operation, operationClass, allowedBy: "confirmation" })
|
|
48
|
+
: refuse(operation, "not confirmed"));
|
|
49
|
+
}
|
|
50
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
// The typed catalog of one SOAP service: what `soap-discover` persists as
|
|
3
|
+
// `catalog.json` and every later step (sample authoring, masking by field,
|
|
4
|
+
// analysis, the 1:1 mapping, the REST design) reads. It is produced by the
|
|
5
|
+
// deterministic WSDL/XSD reader, never by a model. Anything the reader does
|
|
6
|
+
// not model is recorded as an open question instead of being guessed.
|
|
7
|
+
export const CatalogVersion = 1;
|
|
8
|
+
export const XsdNamespace = "http://www.w3.org/2001/XMLSchema";
|
|
9
|
+
/** A qualified name in Clark notation, `{namespace}local`. */
|
|
10
|
+
export const QName = Schema.String;
|
|
11
|
+
export const qname = (namespace, local) => `{${namespace}}${local}`;
|
|
12
|
+
export const splitClark = (name) => {
|
|
13
|
+
const close = name.indexOf("}");
|
|
14
|
+
return name.startsWith("{") && close > 0
|
|
15
|
+
? { namespace: name.slice(1, close), local: name.slice(close + 1) }
|
|
16
|
+
: { namespace: "", local: name };
|
|
17
|
+
};
|
|
18
|
+
export const localName = (name) => splitClark(name).local;
|
|
19
|
+
export const isBuiltin = (name) => splitClark(name).namespace === XsdNamespace;
|
|
20
|
+
export const Occurs = Schema.Union([Schema.Int, Schema.Literal("unbounded")]);
|
|
21
|
+
export class Facets extends Schema.Class("Facets")({
|
|
22
|
+
enumeration: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
23
|
+
pattern: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
24
|
+
length: Schema.optionalKey(Schema.Int),
|
|
25
|
+
minLength: Schema.optionalKey(Schema.Int),
|
|
26
|
+
maxLength: Schema.optionalKey(Schema.Int),
|
|
27
|
+
totalDigits: Schema.optionalKey(Schema.Int),
|
|
28
|
+
fractionDigits: Schema.optionalKey(Schema.Int),
|
|
29
|
+
minInclusive: Schema.optionalKey(Schema.String),
|
|
30
|
+
maxInclusive: Schema.optionalKey(Schema.String),
|
|
31
|
+
minExclusive: Schema.optionalKey(Schema.String),
|
|
32
|
+
maxExclusive: Schema.optionalKey(Schema.String)
|
|
33
|
+
}) {
|
|
34
|
+
}
|
|
35
|
+
/** A child element of a complex type's content model. */
|
|
36
|
+
export class ElementField extends Schema.Class("ElementField")({
|
|
37
|
+
name: Schema.String,
|
|
38
|
+
/** Namespace of the element as it appears on the wire (`""` when unqualified). */
|
|
39
|
+
namespace: Schema.String,
|
|
40
|
+
type: QName,
|
|
41
|
+
minOccurs: Schema.Int,
|
|
42
|
+
maxOccurs: Occurs,
|
|
43
|
+
nillable: Schema.Boolean,
|
|
44
|
+
/** Set when the field sits inside an `xs:choice`: the choice's index within the type. */
|
|
45
|
+
choice: Schema.optionalKey(Schema.Int),
|
|
46
|
+
/** Within a choice: the alternative this field belongs to (a sequence is one alternative). */
|
|
47
|
+
branch: Schema.optionalKey(Schema.Int),
|
|
48
|
+
documentation: Schema.optionalKey(Schema.String)
|
|
49
|
+
}) {
|
|
50
|
+
}
|
|
51
|
+
export class AttributeField extends Schema.Class("AttributeField")({
|
|
52
|
+
name: Schema.String,
|
|
53
|
+
type: QName,
|
|
54
|
+
required: Schema.Boolean,
|
|
55
|
+
documentation: Schema.optionalKey(Schema.String)
|
|
56
|
+
}) {
|
|
57
|
+
}
|
|
58
|
+
export class ComplexTypeDef extends Schema.Class("ComplexTypeDef")({
|
|
59
|
+
_tag: Schema.tag("complex"),
|
|
60
|
+
name: QName,
|
|
61
|
+
/** True for a type declared inline in an element; its name is synthesized. */
|
|
62
|
+
anonymous: Schema.Boolean,
|
|
63
|
+
/** `xs:extension` base, already flattened into `fields`/`attributes`. */
|
|
64
|
+
base: Schema.optionalKey(QName),
|
|
65
|
+
fields: Schema.Array(ElementField),
|
|
66
|
+
attributes: Schema.Array(AttributeField),
|
|
67
|
+
/** Simple content (`xs:simpleContent`): the text value's type. */
|
|
68
|
+
textType: Schema.optionalKey(QName),
|
|
69
|
+
documentation: Schema.optionalKey(Schema.String)
|
|
70
|
+
}) {
|
|
71
|
+
}
|
|
72
|
+
export class SimpleTypeDef extends Schema.Class("SimpleTypeDef")({
|
|
73
|
+
_tag: Schema.tag("simple"),
|
|
74
|
+
name: QName,
|
|
75
|
+
anonymous: Schema.Boolean,
|
|
76
|
+
/** The restriction base (an `xs:` builtin or another simple type). */
|
|
77
|
+
base: QName,
|
|
78
|
+
facets: Facets,
|
|
79
|
+
documentation: Schema.optionalKey(Schema.String)
|
|
80
|
+
}) {
|
|
81
|
+
}
|
|
82
|
+
export const TypeDef = Schema.Union([ComplexTypeDef, SimpleTypeDef]);
|
|
83
|
+
export class ElementDecl extends Schema.Class("ElementDecl")({
|
|
84
|
+
name: QName,
|
|
85
|
+
type: QName,
|
|
86
|
+
nillable: Schema.Boolean,
|
|
87
|
+
documentation: Schema.optionalKey(Schema.String)
|
|
88
|
+
}) {
|
|
89
|
+
}
|
|
90
|
+
export const SoapVersion = Schema.Literals(["1.1", "1.2"]);
|
|
91
|
+
export class FaultRef extends Schema.Class("FaultRef")({
|
|
92
|
+
name: Schema.String,
|
|
93
|
+
element: QName
|
|
94
|
+
}) {
|
|
95
|
+
}
|
|
96
|
+
export class Operation extends Schema.Class("Operation")({
|
|
97
|
+
name: Schema.String,
|
|
98
|
+
soapAction: Schema.String,
|
|
99
|
+
soapVersion: SoapVersion,
|
|
100
|
+
binding: Schema.String,
|
|
101
|
+
/** Document/literal wrapped: the request element carries the operation name. */
|
|
102
|
+
wrapped: Schema.Boolean,
|
|
103
|
+
input: QName,
|
|
104
|
+
output: Schema.optionalKey(QName),
|
|
105
|
+
faults: Schema.Array(FaultRef),
|
|
106
|
+
documentation: Schema.optionalKey(Schema.String)
|
|
107
|
+
}) {
|
|
108
|
+
}
|
|
109
|
+
export class Endpoint extends Schema.Class("Endpoint")({
|
|
110
|
+
service: Schema.String,
|
|
111
|
+
port: Schema.String,
|
|
112
|
+
binding: Schema.String,
|
|
113
|
+
soapVersion: SoapVersion,
|
|
114
|
+
address: Schema.String
|
|
115
|
+
}) {
|
|
116
|
+
}
|
|
117
|
+
export const OpenQuestionCode = Schema.Literals([
|
|
118
|
+
"xsd-any",
|
|
119
|
+
"xsd-any-attribute",
|
|
120
|
+
"substitution-group",
|
|
121
|
+
"abstract",
|
|
122
|
+
"group-ref",
|
|
123
|
+
"attribute-group",
|
|
124
|
+
"complex-restriction",
|
|
125
|
+
"mixed-content",
|
|
126
|
+
"list-or-union",
|
|
127
|
+
"unresolved-type",
|
|
128
|
+
"unresolved-element",
|
|
129
|
+
"non-literal-part",
|
|
130
|
+
"unsupported-binding",
|
|
131
|
+
"multi-part-message",
|
|
132
|
+
"missing-address"
|
|
133
|
+
]);
|
|
134
|
+
export class OpenQuestion extends Schema.Class("OpenQuestion")({
|
|
135
|
+
code: OpenQuestionCode,
|
|
136
|
+
/** Where: `<document>:<line>`, plus the owning type or operation when known. */
|
|
137
|
+
location: Schema.String,
|
|
138
|
+
subject: Schema.String,
|
|
139
|
+
detail: Schema.String
|
|
140
|
+
}) {
|
|
141
|
+
}
|
|
142
|
+
export class WsdlCatalog extends Schema.Class("WsdlCatalog")({
|
|
143
|
+
version: Schema.Literal(CatalogVersion),
|
|
144
|
+
wsdlVersion: Schema.Literals(["1.1", "2.0"]),
|
|
145
|
+
targetNamespace: Schema.String,
|
|
146
|
+
/** Every document read, root first, as given or resolved. */
|
|
147
|
+
documents: Schema.Array(Schema.String),
|
|
148
|
+
endpoints: Schema.Array(Endpoint),
|
|
149
|
+
operations: Schema.Array(Operation),
|
|
150
|
+
elements: Schema.Array(ElementDecl),
|
|
151
|
+
types: Schema.Array(TypeDef),
|
|
152
|
+
openQuestions: Schema.Array(OpenQuestion)
|
|
153
|
+
}) {
|
|
154
|
+
}
|
|
155
|
+
export const typeByName = (catalog, name) => catalog.types.find((type) => type.name === name);
|
|
156
|
+
export const elementByName = (catalog, name) => catalog.elements.find((element) => element.name === name);
|
|
157
|
+
export const operationByName = (catalog, name) => catalog.operations.find((operation) => operation.name === name);
|