@llm4ts/shell 2.26.0 → 2.28.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.
@@ -2,7 +2,7 @@ import { basename } from "node:path";
2
2
  import * as Effect from "effect/Effect";
3
3
  import * as Schema from "effect/Schema";
4
4
  import { ComplexTypeDef, elementByName, localName, splitClark, typeByName, WsdlCatalog } from "./Catalog.js";
5
- import { decodeAuthProfile } from "./Auth.js";
5
+ import { AuthProfileError, decodeAuthProfile, profileFileName } from "./Auth.js";
6
6
  import { classifyOperations, operationsDrift, parseOperationsFile, renderOperationsFile } from "./Classification.js";
7
7
  import { readCatalog } from "./Wsdl.js";
8
8
  // `soap-discover` as a library call: read the catalog, persist it with a
@@ -26,10 +26,14 @@ export const servicePaths = (service) => {
26
26
  summary: `${directory}/catalog.md`,
27
27
  operations: `${directory}/operations.md`,
28
28
  auth: `${directory}/auth.json`,
29
+ /** Captured server chains, one PEM per host (`trust`). */
30
+ trustDir: `${directory}/trust`,
29
31
  masking: `${directory}/masking.json`,
30
32
  gitignore: `${directory}/.gitignore`
31
33
  };
32
34
  };
35
+ /** The profile file an environment selects: `auth.json`, or `auth.<env>.json`. */
36
+ export const profilePath = (service, env) => `${serviceDirectory(service)}/${profileFileName(env)}`;
33
37
  /** A directory-safe service name: explicit, else the WSDL's service, else the file name. */
34
38
  export const serviceName = (catalog, location, explicit) => {
35
39
  const raw = explicit ??
@@ -39,8 +43,32 @@ export const serviceName = (catalog, location, explicit) => {
39
43
  return safe === "" ? "service" : safe;
40
44
  };
41
45
  /** What never leaves the machine: credentials references, raw captures, the masking key. */
42
- export const serviceGitignore = ["auth.json", "raw/", ".masking-key", ""].join("\n");
43
- export const loadAuthProfile = (workspace, service) => Effect.flatMap(readIfPresent(workspace, servicePaths(service).auth), (text) => text === undefined ? Effect.succeed(undefined) : decodeAuthProfile(text));
46
+ export const serviceGitignore = [
47
+ "auth.json",
48
+ "auth.*.json",
49
+ "trust/",
50
+ "raw/",
51
+ ".masking-key",
52
+ ""
53
+ ].join("\n");
54
+ /**
55
+ * The profile of the selected environment (`LLM4TS_SOAP_ENV` / `--env`):
56
+ * `auth.<env>.json` when one is named, which must then exist; else `auth.json`.
57
+ */
58
+ export const loadAuthProfile = (workspace, service, env) => Effect.gen(function* () {
59
+ const path = profilePath(service, env);
60
+ const text = yield* readIfPresent(workspace, path);
61
+ if (text === undefined) {
62
+ if (env !== undefined && env.trim() !== "") {
63
+ return yield* new AuthProfileError({
64
+ subject: path,
65
+ detail: `no profile for environment '${env.trim()}'; write it, or unset LLM4TS_SOAP_ENV`
66
+ });
67
+ }
68
+ return undefined;
69
+ }
70
+ return yield* decodeAuthProfile(text, path.split("/").at(-1) ?? "auth.json");
71
+ });
44
72
  export const readIfPresent = (workspace, path) => Effect.flatMap(workspace.discover(path), (found) => found.includes(path) ? workspace.read(path) : Effect.succeed(undefined));
45
73
  export const encodeCatalog = (catalog) => `${JSON.stringify(Schema.encodeSync(WsdlCatalog)(catalog), null, 2)}\n`;
46
74
  export const loadCatalog = (workspace, service) => {
@@ -25,21 +25,46 @@ export const passwordDigest = (material, password) => createHash("sha1")
25
25
  .update(material.created, "utf8")
26
26
  .update(password, "utf8")
27
27
  .digest("base64");
28
- export const usernameTokenHeader = (token, material) => {
28
+ const usernameTokenElement = (token, material) => {
29
29
  const password = Redacted.value(token.password);
30
30
  const passwordValue = token.passwordType === "digest" ? passwordDigest(material, password) : password;
31
31
  const type = token.passwordType === "digest" ? "PasswordDigest" : "PasswordText";
32
- return Redacted.make([
33
- `<wsse:Security xmlns:wsse="${WsseNamespace}" xmlns:wsu="${WsuNamespace}" soapenv:mustUnderstand="1">`,
32
+ return [
34
33
  '<wsse:UsernameToken wsu:Id="UsernameToken-1">',
35
34
  `<wsse:Username>${escapeText(token.user)}</wsse:Username>`,
36
35
  `<wsse:Password Type="${TokenProfile}#${type}">${escapeText(passwordValue)}</wsse:Password>`,
37
36
  `<wsse:Nonce EncodingType="${Base64Binary}">${Buffer.from(material.nonce).toString("base64")}</wsse:Nonce>`,
38
37
  `<wsu:Created>${material.created}</wsu:Created>`,
39
- "</wsse:UsernameToken>",
38
+ "</wsse:UsernameToken>"
39
+ ].join("");
40
+ };
41
+ const fiveMinutesAfter = (created) => new Date(new Date(created).getTime() + 5 * 60_000).toISOString();
42
+ /**
43
+ * The WS-Security header, in the order ESBs expect: Timestamp, then the
44
+ * UsernameToken, then the bearer token. Empty parts are left out; no parts
45
+ * at all gives no header.
46
+ */
47
+ export const securityHeader = (parts) => {
48
+ const inner = [];
49
+ if (parts.timestamp !== undefined) {
50
+ inner.push('<wsu:Timestamp wsu:Id="TS-1">', `<wsu:Created>${parts.timestamp.created}</wsu:Created>`, `<wsu:Expires>${parts.timestamp.expires ?? fiveMinutesAfter(parts.timestamp.created)}</wsu:Expires>`, "</wsu:Timestamp>");
51
+ }
52
+ if (parts.usernameToken !== undefined) {
53
+ inner.push(usernameTokenElement(parts.usernameToken, parts.material));
54
+ }
55
+ if (parts.token !== undefined) {
56
+ inner.push(Redacted.value(parts.token));
57
+ }
58
+ if (inner.length === 0) {
59
+ return undefined;
60
+ }
61
+ return Redacted.make([
62
+ `<wsse:Security xmlns:wsse="${WsseNamespace}" xmlns:wsu="${WsuNamespace}" soapenv:mustUnderstand="1">`,
63
+ ...inner,
40
64
  "</wsse:Security>"
41
65
  ].join(""));
42
66
  };
67
+ export const usernameTokenHeader = (token, material) => securityHeader({ usernameToken: token, material }) ?? Redacted.make("");
43
68
  /**
44
69
  * Wrap a body element (serialized XML, carrying its own namespace
45
70
  * declarations) in an envelope. The header, when present, is secret
@@ -247,6 +247,7 @@ export const exploreService = (options) => Effect.gen(function* () {
247
247
  operations: probeFile,
248
248
  secrets: options.secrets,
249
249
  transport: options.transport,
250
+ ...(options.token === undefined ? {} : { token: options.token }),
250
251
  allowMutating: undefined,
251
252
  confirm: () => Effect.succeed(false),
252
253
  ...(body === undefined
@@ -0,0 +1,202 @@
1
+ import * as Duration from "effect/Duration";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Schema from "effect/Schema";
4
+ import { resolveProxy, resolveTls } from "./Auth.js";
5
+ import { profilePath, readIfPresent, servicePaths } from "./Discover.js";
6
+ import { renderDefaultStsTemplate, stsTemplatePath, templatePlaceholders } from "./Sts.js";
7
+ import { displayUrl } from "./Transport.js";
8
+ const proxyWords = (proxy) => proxy === undefined || proxy === "none"
9
+ ? "direct (no proxy)"
10
+ : `via ${proxy.url}${(proxy.noProxy ?? []).length === 0 ? "" : `, direct for ${proxy.noProxy?.join(", ")}`}${proxy.auth === undefined ? "" : ", with proxy credentials"}`;
11
+ const tlsWords = (tls) => {
12
+ if (tls === undefined) {
13
+ return "system roots";
14
+ }
15
+ const parts = [];
16
+ parts.push(tls.trust === "ca-only"
17
+ ? "profile CA only"
18
+ : tls.ca === undefined && tls.caDir === undefined
19
+ ? "system roots"
20
+ : "system roots + profile CA");
21
+ if (tls.ca !== undefined)
22
+ parts.push(`ca ${tls.ca}`);
23
+ if (tls.caDir !== undefined)
24
+ parts.push(`caDir ${tls.caDir}`);
25
+ if (tls.pfx !== undefined)
26
+ parts.push("client pfx");
27
+ if (tls.cert !== undefined)
28
+ parts.push("client cert+key");
29
+ if (tls.servername !== undefined)
30
+ parts.push(`servername ${tls.servername}`);
31
+ return parts.join(", ");
32
+ };
33
+ const authWords = (side) => side?.auth === undefined || side.auth.scheme === "none"
34
+ ? "no auth"
35
+ : side.auth.scheme === "basic"
36
+ ? `basic as ${side.auth.user}`
37
+ : "bearer";
38
+ /** The proxy variables of the environment, which the soap flows never read. */
39
+ export const ignoredProxyVariables = (environment) => ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"].filter((name) => (environment[name] ?? "").trim() !== "");
40
+ /** `check`: the selected profile as the decisions it makes, line by line. No network. */
41
+ export const checkReport = (options) => Effect.gen(function* () {
42
+ const path = profilePath(options.service, options.env);
43
+ const lines = [
44
+ `profile: ${path}${options.profile === undefined ? " (missing)" : ""}`
45
+ ];
46
+ const profile = options.profile;
47
+ if (profile === undefined) {
48
+ lines.push(` write it with at least {"environment": "dev"}; see the soap-ace README`);
49
+ }
50
+ else {
51
+ lines.push(`environment: ${profile.environment}`);
52
+ lines.push(`call endpoint: ${profile.endpoint ?? "(from the WSDL)"}`);
53
+ for (const [name, side] of [
54
+ ["fetch", profile.fetch],
55
+ ["call", profile.call]
56
+ ]) {
57
+ lines.push(`${name}: ${authWords(side)}; ${proxyWords(side?.proxy)}; tls ${tlsWords(side?.tls)}`);
58
+ }
59
+ if (profile.wsSecurity !== undefined) {
60
+ lines.push(`ws-security: UsernameToken (${profile.wsSecurity.passwordType})`);
61
+ }
62
+ if (profile.sts === undefined) {
63
+ lines.push("sts: none");
64
+ }
65
+ else {
66
+ const template = stsTemplatePath(options.service, profile.sts);
67
+ const text = yield* readIfPresent(options.workspace, template);
68
+ lines.push(`sts: ${displayUrl(profile.sts.endpoint)}; ${proxyWords(profile.sts.proxy)}; tls ${tlsWords(profile.sts.tls)}; token element ${profile.sts.token?.element ?? "Assertion"}`);
69
+ lines.push(text === undefined
70
+ ? ` template ${template} missing: write it with soap-sample "sts init"`
71
+ : ` template ${template} fills ${templatePlaceholders(text).join(", ") || "(no placeholders)"}`);
72
+ }
73
+ const trust = yield* options.workspace.discover(`${servicePaths(options.service).trustDir}/*.pem`);
74
+ if (trust.length > 0) {
75
+ lines.push(`trusted chains: ${trust.map((file) => file.split("/").at(-1)).join(", ")}`);
76
+ }
77
+ }
78
+ const ignored = ignoredProxyVariables(options.environment);
79
+ if (ignored.length > 0) {
80
+ lines.push(`ignored: ${ignored.join(", ")} are set; the soap flows use only the profile's proxy`);
81
+ }
82
+ return lines;
83
+ });
84
+ // ---------------------------------------------------------------------------
85
+ // trust
86
+ export class TrustError extends Schema.TaggedError()("TrustError", {
87
+ side: Schema.String,
88
+ detail: Schema.String
89
+ }) {
90
+ get message() {
91
+ return `trust ${this.side}: ${this.detail}`;
92
+ }
93
+ }
94
+ const endpointOf = (profile, side, fallback) => (side === "sts" ? profile.sts?.endpoint : (profile.endpoint ?? fallback));
95
+ const sideConfig = (profile, side) => side === "sts"
96
+ ? {
97
+ ...(profile.sts?.tls === undefined ? {} : { tls: profile.sts.tls }),
98
+ ...(profile.sts?.proxy === undefined ? {} : { proxy: profile.sts.proxy })
99
+ }
100
+ : {
101
+ ...(profile[side]?.tls === undefined ? {} : { tls: profile[side]?.tls }),
102
+ ...(profile[side]?.proxy === undefined ? {} : { proxy: profile[side]?.proxy })
103
+ };
104
+ export const describeChain = (chain) => [
105
+ `${chain.host}: ${chain.certificates.length} certificate(s)${chain.trustedBySystem ? ", trusted by the system roots" : `, NOT trusted by the system roots (${chain.authorizationError ?? "unknown issuer"})`}`,
106
+ ...chain.certificates.flatMap((certificate, index) => [
107
+ ` ${index + 1}. ${certificate.subject}`,
108
+ ` issuer: ${certificate.issuer}`,
109
+ ` valid: ${certificate.validFrom} → ${certificate.validTo}`,
110
+ ` sha256: ${certificate.fingerprint256}`
111
+ ])
112
+ ];
113
+ const pemFileFor = (service, host) => `${servicePaths(service).trustDir}/${host.replace(/[^A-Za-z0-9.-]+/g, "_")}.pem`;
114
+ /** Sets `<side>.tls.ca` in the selected profile's JSON text to the pinned file, keeping everything else. */
115
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
116
+ export const profileWithPinnedCa = (text, side, pemPath) => Effect.try({
117
+ try: () => {
118
+ const parsed = JSON.parse(text);
119
+ if (!isRecord(parsed)) {
120
+ throw new Error("not an object");
121
+ }
122
+ const sideValue = parsed[side];
123
+ const sideObject = isRecord(sideValue) ? sideValue : {};
124
+ const tlsValue = sideObject["tls"];
125
+ const tlsObject = isRecord(tlsValue) ? tlsValue : {};
126
+ const edited = {
127
+ ...parsed,
128
+ [side]: { ...sideObject, tls: { ...tlsObject, ca: `file:${pemPath}` } }
129
+ };
130
+ return `${JSON.stringify(edited, null, 2)}\n`;
131
+ },
132
+ catch: () => new TrustError({ side, detail: "the profile is not a JSON object; set tls.ca by hand" })
133
+ });
134
+ /** `trust <side>`: show the chain, pin it on a yes. */
135
+ export const trustSide = (options) => Effect.gen(function* () {
136
+ const endpoint = endpointOf(options.profile, options.side, options.fallbackEndpoint);
137
+ if (endpoint === undefined) {
138
+ return yield* new TrustError({
139
+ side: options.side,
140
+ detail: options.side === "sts"
141
+ ? "the profile names no sts"
142
+ : "no endpoint in the profile or the WSDL"
143
+ });
144
+ }
145
+ const config = sideConfig(options.profile, options.side);
146
+ const proxy = yield* resolveProxy(options.secrets, config.proxy);
147
+ const tls = yield* resolveTls(options.secrets, config.tls);
148
+ const chain = yield* options.transport.peerChain(endpoint, {
149
+ ...(proxy === undefined ? {} : { proxy }),
150
+ ...(tls?.servername === undefined ? {} : { servername: tls.servername }),
151
+ timeout: Duration.seconds(20)
152
+ });
153
+ const lines = describeChain(chain);
154
+ if (chain.trustedBySystem) {
155
+ return { _tag: "AlreadyTrusted", lines };
156
+ }
157
+ if (chain.certificates.length === 0) {
158
+ return yield* new TrustError({
159
+ side: options.side,
160
+ detail: "the server presented no certificate"
161
+ });
162
+ }
163
+ const pemPath = pemFileFor(options.service, chain.host);
164
+ const existing = yield* readIfPresent(options.workspace, pemPath);
165
+ const shown = existing === undefined
166
+ ? lines
167
+ : [
168
+ ...lines,
169
+ `a chain for ${chain.host} is already pinned at ${pemPath}; this one replaces it`
170
+ ];
171
+ if (!(yield* options.confirm(shown))) {
172
+ return { _tag: "Declined", lines: shown };
173
+ }
174
+ yield* options.workspace.write(pemPath, chain.certificates.map((c) => c.pem).join(""));
175
+ const profileFile = profilePath(options.service, options.env);
176
+ const text = (yield* readIfPresent(options.workspace, profileFile)) ?? "{}";
177
+ yield* options.workspace.write(profileFile, yield* profileWithPinnedCa(text, options.side, pemPath));
178
+ return {
179
+ _tag: "Pinned",
180
+ lines: shown,
181
+ pem: pemPath,
182
+ profile: profileFile,
183
+ replaced: existing !== undefined
184
+ };
185
+ });
186
+ // ---------------------------------------------------------------------------
187
+ // sts init
188
+ /** `sts init`: the default template, unless one exists. */
189
+ export const initStsTemplate = (workspace, service, profile) => Effect.gen(function* () {
190
+ if (profile.sts === undefined) {
191
+ return yield* new TrustError({
192
+ side: "sts",
193
+ detail: 'the profile names no "sts"; add it first'
194
+ });
195
+ }
196
+ const path = stsTemplatePath(service, profile.sts);
197
+ if ((yield* readIfPresent(workspace, path)) !== undefined) {
198
+ return { path, written: false };
199
+ }
200
+ yield* workspace.write(path, renderDefaultStsTemplate(profile.sts.soapVersion ?? "1.1"));
201
+ return { path, written: true };
202
+ });
@@ -0,0 +1,216 @@
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 Ref from "effect/Ref";
6
+ import * as Schema from "effect/Schema";
7
+ import { defaultStsTemplate, defaultTokenElement, resolvePrincipal, resolveProxy, resolveText, resolveTls } from "./Auth.js";
8
+ import { envelopeNamespace, readEnvelope, soapHeaders, WsseNamespace, WsuNamespace } from "./Envelope.js";
9
+ import { serviceDirectory } from "./Discover.js";
10
+ import { displayUrl } from "./Transport.js";
11
+ import { decodeXml, parseXml } from "./Xml.js";
12
+ // A SAML bearer token from an STS (WS-Trust): the request is a template the
13
+ // user owns under the service folder, filled with the credential, a nonce
14
+ // and timestamps, posted with the STS side's auth, TLS and proxy; the first
15
+ // element named `token.element` in the answer is carried as the raw bytes
16
+ // of the response, so a signature over it survives. The token lives in
17
+ // memory for the run only and is renewed before it expires.
18
+ export class StsError extends Schema.TaggedError()("StsError", {
19
+ endpoint: Schema.String,
20
+ detail: Schema.String
21
+ }) {
22
+ get message() {
23
+ return `STS ${this.endpoint}: ${this.detail}`;
24
+ }
25
+ }
26
+ export const stsTemplatePath = (service, config) => `${serviceDirectory(service)}/${config.template ?? defaultStsTemplate}`;
27
+ const WsTrust = "http://docs.oasis-open.org/ws-sx/ws-trust/200512";
28
+ const WsPolicy = "http://schemas.xmlsoap.org/ws/2004/09/policy";
29
+ const WsAddressing = "http://www.w3.org/2005/08/addressing";
30
+ const TokenProfile = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0";
31
+ /** The WS-Trust 1.3 Issue request `sts init` writes; every placeholder is filled per call. */
32
+ export const renderDefaultStsTemplate = (soapVersion = "1.1") => [
33
+ '<?xml version="1.0" encoding="UTF-8"?>',
34
+ "<!-- The STS request soap-sample fills before every token call. Placeholders:",
35
+ " {{username}} {{password}} {{created}} {{expires}} {{nonce}} {{appliesTo}}.",
36
+ " Edit freely: this file is yours; soap-sample only fills the placeholders. -->",
37
+ `<soapenv:Envelope xmlns:soapenv="${envelopeNamespace(soapVersion)}"`,
38
+ ` xmlns:wsse="${WsseNamespace}"`,
39
+ ` xmlns:wsu="${WsuNamespace}"`,
40
+ ` xmlns:wst="${WsTrust}"`,
41
+ ` xmlns:wsp="${WsPolicy}"`,
42
+ ` xmlns:wsa="${WsAddressing}">`,
43
+ " <soapenv:Header>",
44
+ ` <wsa:Action>${WsTrust}/RST/Issue</wsa:Action>`,
45
+ ' <wsse:Security soapenv:mustUnderstand="1">',
46
+ ' <wsu:Timestamp wsu:Id="TS-1">',
47
+ " <wsu:Created>{{created}}</wsu:Created>",
48
+ " <wsu:Expires>{{expires}}</wsu:Expires>",
49
+ " </wsu:Timestamp>",
50
+ ' <wsse:UsernameToken wsu:Id="UsernameToken-1">',
51
+ " <wsse:Username>{{username}}</wsse:Username>",
52
+ ` <wsse:Password Type="${TokenProfile}#PasswordText">{{password}}</wsse:Password>`,
53
+ " <wsse:Nonce>{{nonce}}</wsse:Nonce>",
54
+ " <wsu:Created>{{created}}</wsu:Created>",
55
+ " </wsse:UsernameToken>",
56
+ " </wsse:Security>",
57
+ " </soapenv:Header>",
58
+ " <soapenv:Body>",
59
+ " <wst:RequestSecurityToken>",
60
+ ` <wst:RequestType>${WsTrust}/Issue</wst:RequestType>`,
61
+ " <wst:TokenType>http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0</wst:TokenType>",
62
+ ` <wst:KeyType>${WsTrust}/Bearer</wst:KeyType>`,
63
+ " <wsp:AppliesTo>",
64
+ " <wsa:EndpointReference>",
65
+ " <wsa:Address>{{appliesTo}}</wsa:Address>",
66
+ " </wsa:EndpointReference>",
67
+ " </wsp:AppliesTo>",
68
+ " </wst:RequestSecurityToken>",
69
+ " </soapenv:Body>",
70
+ "</soapenv:Envelope>",
71
+ ""
72
+ ].join("\n");
73
+ const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
74
+ /** Fills `{{name}}` placeholders; values are escaped for XML text. Unknown placeholders stay. */
75
+ export const fillStsTemplate = (template, values) => {
76
+ const table = {
77
+ username: escapeXml(values.username),
78
+ password: escapeXml(Redacted.value(values.password)),
79
+ created: values.created,
80
+ expires: values.expires,
81
+ nonce: values.nonce,
82
+ appliesTo: escapeXml(values.appliesTo)
83
+ };
84
+ return Redacted.make(template.replace(/\{\{\s*([a-zA-Z]+)\s*\}\}/g, (whole, name) => table[name] ?? whole));
85
+ };
86
+ /** The names the template fills, for `check` to compare with what it holds. */
87
+ export const templatePlaceholders = (template) => [...new Set([...template.matchAll(/\{\{\s*([a-zA-Z]+)\s*\}\}/g)].map((m) => m[1] ?? ""))].sort();
88
+ const findElement = (root, local) => {
89
+ if (root.name.local === local) {
90
+ return root;
91
+ }
92
+ for (const child of root.children) {
93
+ if (child._tag === "element") {
94
+ const found = findElement(child, local);
95
+ if (found !== undefined) {
96
+ return found;
97
+ }
98
+ }
99
+ }
100
+ return undefined;
101
+ };
102
+ /**
103
+ * The first element named `local` in `text`, exactly as written (its span),
104
+ * or `undefined`. The text must be the parsed document: spans index it.
105
+ */
106
+ export const extractElementRaw = (text, root, local) => {
107
+ const found = findElement(root, local);
108
+ return found?.span === undefined ? undefined : text.slice(found.span.start, found.span.end);
109
+ };
110
+ const attributeOf = (element, local) => element.attributes.find((attribute) => attribute.name.local === local)?.value;
111
+ /** `Conditions/@NotOnOrAfter` of a SAML assertion, when it carries one. */
112
+ export const notOnOrAfterOf = (root, tokenLocal) => {
113
+ const assertion = findElement(root, tokenLocal);
114
+ if (assertion === undefined) {
115
+ return undefined;
116
+ }
117
+ const conditions = findElement(assertion, "Conditions");
118
+ const value = conditions === undefined ? undefined : attributeOf(conditions, "NotOnOrAfter");
119
+ if (value === undefined) {
120
+ return undefined;
121
+ }
122
+ const parsed = new Date(value);
123
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
124
+ };
125
+ /** One STS call: fill the template, post it, carry the token element verbatim. */
126
+ export const fetchSamlToken = (options) => Effect.gen(function* () {
127
+ const { config } = options;
128
+ const shown = displayUrl(config.endpoint);
129
+ const path = stsTemplatePath(options.service, config);
130
+ const found = yield* options.workspace.discover(path);
131
+ if (!found.includes(path)) {
132
+ return yield* new StsError({
133
+ endpoint: shown,
134
+ detail: `no request template at ${path}; write it with soap-sample "sts init"`
135
+ });
136
+ }
137
+ const template = yield* options.workspace.read(path);
138
+ const now = options.now ?? (() => new Date());
139
+ const random = options.random ?? ((size) => new Uint8Array(randomBytes(size)));
140
+ const created = now();
141
+ const envelope = fillStsTemplate(template, {
142
+ username: yield* resolvePrincipal(options.secrets, config.auth.user),
143
+ password: yield* resolveText(options.secrets, config.auth.password),
144
+ created: created.toISOString(),
145
+ expires: new Date(created.getTime() + 5 * 60_000).toISOString(),
146
+ nonce: Buffer.from(random(16)).toString("base64"),
147
+ appliesTo: config.appliesTo ?? ""
148
+ });
149
+ const tls = yield* resolveTls(options.secrets, config.tls);
150
+ const proxy = yield* resolveProxy(options.secrets, config.proxy);
151
+ const version = config.soapVersion ?? "1.1";
152
+ const response = yield* options.transport.send({
153
+ method: "POST",
154
+ url: config.endpoint,
155
+ headers: soapHeaders(version, `${WsTrust}/RST/Issue`),
156
+ secretHeaders: [],
157
+ body: envelope,
158
+ ...(tls === undefined ? {} : { tls }),
159
+ ...(proxy === undefined ? {} : { proxy }),
160
+ timeout: Duration.seconds(config.timeoutSeconds ?? 60)
161
+ });
162
+ const text = yield* decodeXml(response.body, "sts response");
163
+ const root = yield* parseXml(text, { source: "sts response" }).pipe(Effect.mapError(() => new StsError({
164
+ endpoint: shown,
165
+ detail: `HTTP ${response.status} with a body that is not XML`
166
+ })));
167
+ const read = yield* readEnvelope(root).pipe(Effect.option);
168
+ if (read._tag === "Some" && read.value.fault !== undefined) {
169
+ return yield* new StsError({
170
+ endpoint: shown,
171
+ detail: `fault ${read.value.fault.code}: ${read.value.fault.reason}`
172
+ });
173
+ }
174
+ if (response.status < 200 || response.status >= 300) {
175
+ return yield* new StsError({ endpoint: shown, detail: `HTTP ${response.status}` });
176
+ }
177
+ const element = config.token?.element ?? defaultTokenElement;
178
+ const raw = extractElementRaw(text, root, element);
179
+ if (raw === undefined) {
180
+ return yield* new StsError({
181
+ endpoint: shown,
182
+ detail: `no ${element} element in the response (set sts.token.element to the one it carries)`
183
+ });
184
+ }
185
+ return {
186
+ token: Redacted.make(raw),
187
+ notOnOrAfter: notOnOrAfterOf(root, element),
188
+ element,
189
+ chars: raw.length
190
+ };
191
+ });
192
+ /**
193
+ * The run's token: fetched on first use, renewed `renewBeforeSeconds`
194
+ * before `NotOnOrAfter` (a token without one lasts the run). Never persisted.
195
+ */
196
+ export const makeTokenSource = (options) => Effect.gen(function* () {
197
+ const cache = yield* Ref.make(undefined);
198
+ const count = yield* Ref.make(0);
199
+ const now = options.now ?? (() => new Date());
200
+ const margin = (options.config.renewBeforeSeconds ?? 60) * 1000;
201
+ const fresh = (token) => token !== undefined &&
202
+ (token.notOnOrAfter === undefined || token.notOnOrAfter.getTime() - margin > now().getTime());
203
+ return {
204
+ current: Effect.gen(function* () {
205
+ const cached = yield* Ref.get(cache);
206
+ if (fresh(cached)) {
207
+ return cached;
208
+ }
209
+ const token = yield* fetchSamlToken(options);
210
+ yield* Ref.set(cache, token);
211
+ yield* Ref.update(count, (n) => n + 1);
212
+ return token;
213
+ }),
214
+ fetches: Ref.get(count)
215
+ };
216
+ });
@@ -30,5 +30,12 @@ export const makeDirectoryStubTransport = (directory, catalog) => ({
30
30
  body: new Uint8Array(bytes),
31
31
  elapsedMs: 1
32
32
  })), Effect.orElseSucceed(() => stubFault(`stub: no ${operation.name}.xml`)));
33
- }
33
+ },
34
+ // A stub answers from files: no server, so no chain to show.
35
+ peerChain: (url) => Effect.succeed({
36
+ host: new URL(url).hostname,
37
+ certificates: [],
38
+ trustedBySystem: true,
39
+ authorizationError: undefined
40
+ })
34
41
  });