@llm4ts/shell 2.27.0 → 2.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,6 +116,10 @@ llm4ts run soap-sample --repo . "propose cercaMovimenti" # scena
116
116
  llm4ts run soap-sample --repo . "call cercaMovimenti" # every request of the operation
117
117
  llm4ts run soap-sample --repo . -- --allow-mutating revocaBonifico "call revocaBonifico/gia-eseguito"
118
118
  llm4ts run soap-sample --repo . list
119
+ llm4ts run soap-sample --repo . "check" # the selected profile as decisions, no network
120
+ llm4ts run soap-sample --repo . "trust call" # capture and pin the chain the endpoint presents
121
+ llm4ts run soap-sample --repo . "sts init" # write the STS request template
122
+ llm4ts run soap-sample --repo . -- --env uat "call all" # with auth.uat.json (or LLM4TS_SOAP_ENV=uat)
119
123
  ```
120
124
 
121
125
  A request is a YAML projection of the XSD, one file per scenario under
@@ -312,6 +316,70 @@ read, and errors name the reference, never its value.
312
316
  Persisted exchanges drop `wsse:Security` headers and credential-bearing HTTP
313
317
  headers (`authorization`, cookies, anything named like a token or secret).
314
318
 
319
+ ## Environments, proxies, trust and STS tokens
320
+
321
+ Locked-down environments differ in where the proxy is, whose CA signed the
322
+ ESB, and which STS issues the token (ADR 0024). One file per environment
323
+ holds all of it: `auth.<env>.json` beside `auth.json`, selected by
324
+ `LLM4TS_SOAP_ENV=uat` or `-- --env uat`; a named environment without a
325
+ file is an error. Every flow prints the file it loaded.
326
+
327
+ ```json
328
+ {
329
+ "environment": "uat",
330
+ "endpoint": "https://esb-uat.bank.internal/soap/DemoBank",
331
+ "call": {
332
+ "proxy": "none",
333
+ "tls": { "ca": "file:.llm4ts/soap/DemoBank/trust/esb-uat.bank.internal.pem" }
334
+ },
335
+ "fetch": {
336
+ "proxy": { "url": "http://proxy.bank.internal:3128", "noProxy": ["*.bank.internal"] }
337
+ },
338
+ "sts": {
339
+ "endpoint": "https://sts-uat.bank.internal/trust/13/usernamemixed",
340
+ "auth": { "user": "env:STS_USER", "password": "env:STS_PASSWORD" },
341
+ "appliesTo": "https://esb-uat.bank.internal/soap/DemoBank",
342
+ "token": { "element": "Assertion" },
343
+ "renewBeforeSeconds": 60,
344
+ "timestamp": true
345
+ }
346
+ }
347
+ ```
348
+
349
+ - **Proxies** are explicit per side (`fetch`, `call`, `sts`): `{ "url",
350
+ "noProxy": ["host", "*.suffix"], "auth": {basic, references} }` or
351
+ `"none"` (the default). The transport opens an HTTP CONNECT tunnel; the
352
+ proxy environment variables are never read, and `check` lists them as
353
+ set-and-ignored. A refused tunnel is a `proxy` transport error.
354
+ - **CA material** adds to Node's system roots: `tls.ca` (a PEM file) and
355
+ `tls.caDir` (a directory of `.pem`/`.crt`). `tls.trust: "ca-only"` trusts
356
+ only the profile's. `tls.servername` names the host behind a front door.
357
+ Verification is never switched off.
358
+ - **`trust call|fetch|sts`** connects to that side's endpoint without
359
+ verifying, sends nothing, prints every certificate of the chain (subject,
360
+ issuer, validity, SHA-256 fingerprint) and whether the system already
361
+ trusts it, asks `trust this chain? [y/N]` (`-- --yes` headless), and on
362
+ yes writes `trust/<host>.pem` (gitignored) and sets that side's `tls.ca`
363
+ in the selected profile. A changed certificate is shown as a replacement
364
+ and asked again. A `tls` error that says "capture it with trust" is this
365
+ case.
366
+ - **STS**: with an `sts` section every `call` first obtains a SAML bearer
367
+ token. `sts init` writes `sts.request.xml`, a WS-Trust 1.3 Issue envelope
368
+ with `{{username}}`, `{{password}}`, `{{created}}`, `{{expires}}`,
369
+ `{{nonce}}`, `{{appliesTo}}`; edit it when the STS differs, the kit only
370
+ fills the placeholders. The first element named `token.element` (default
371
+ `Assertion`, any namespace) is taken from the response as its raw bytes,
372
+ so a signed assertion survives, and sent inside `wsse:Security` after the
373
+ `wsu:Timestamp` (`timestamp: true`) and the UsernameToken when
374
+ `wsSecurity` is also set. The token lives in memory for the run and is
375
+ renewed `renewBeforeSeconds` before its `NotOnOrAfter`; it is never
376
+ written anywhere. `StsError` names the fault, the status or the element
377
+ it looked for.
378
+ - **`check`** prints the selected file, each side's endpoint, proxy decision
379
+ and CA sources, the STS and its template's placeholders, the pinned
380
+ chains, and the ignored proxy variables. It makes no connection: run it
381
+ first, then `trust`, then `sts init`, then `call`.
382
+
315
383
  ## Masking
316
384
 
317
385
  Every SOAP sample is masked before it is written anywhere or shown to a
@@ -59,6 +59,21 @@ and set `LLM4TS_SOAP_SERVICE`.
59
59
  }
60
60
  ```
61
61
 
62
+ Per environment, `auth.test.json` / `auth.uat.json` next to it, picked by
63
+ `LLM4TS_SOAP_ENV` or `-- --env`. In a locked-down network run, in order:
64
+
65
+ ```bash
66
+ llm4ts run soap-sample --repo . "check" # the profile as decisions, no network
67
+ llm4ts run soap-sample --repo . "trust call" # pin the ESB's chain after reading its fingerprint
68
+ llm4ts run soap-sample --repo . "trust sts" # and the STS's, when the profile names one
69
+ llm4ts run soap-sample --repo . "sts init" # the WS-Trust template; edit it if the STS differs
70
+ ```
71
+
72
+ Proxies are explicit per side in the profile (`proxy`), never read from
73
+ the environment; CA PEMs add to the system roots; the SAML token the STS
74
+ issues is carried byte for byte and never persisted (README, "Environments,
75
+ proxies, trust and STS tokens").
76
+
62
77
  ## 3. Samples
63
78
 
64
79
  ```bash
@@ -1,4 +1,5 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
2
3
  import * as Effect from "effect/Effect";
3
4
  import * as Redacted from "effect/Redacted";
4
5
  import * as Schema from "effect/Schema";
@@ -31,20 +32,75 @@ export class NoAuth extends Schema.Class("NoAuth")({
31
32
  }) {
32
33
  }
33
34
  export const HttpAuth = Schema.Union([NoAuth, BasicAuth, BearerAuth]);
34
- /** Client TLS: PEM cert+key, or a PKCS#12 bundle; an optional CA bundle. */
35
+ /** How the profile's CA material combines with Node's system roots. */
36
+ export const TlsTrust = Schema.Literals(["system+ca", "ca-only"]);
37
+ /**
38
+ * Client TLS: PEM cert+key, or a PKCS#12 bundle; CA material as a PEM file
39
+ * (`ca`) and/or a directory of PEMs (`caDir`), added to the system roots
40
+ * unless `trust` is `ca-only`; `servername` for a host behind a front door.
41
+ */
35
42
  export class TlsConfig extends Schema.Class("TlsConfig")({
36
43
  cert: Schema.optionalKey(SecretRef),
37
44
  key: Schema.optionalKey(SecretRef),
38
45
  pfx: Schema.optionalKey(SecretRef),
39
46
  passphrase: Schema.optionalKey(SecretRef),
40
- ca: Schema.optionalKey(SecretRef)
47
+ ca: Schema.optionalKey(SecretRef),
48
+ caDir: Schema.optionalKey(Schema.String),
49
+ trust: Schema.optionalKey(TlsTrust),
50
+ servername: Schema.optionalKey(Schema.String)
41
51
  }) {
42
52
  }
53
+ const ProxyUrl = Schema.String.check(Schema.isPattern(/^https?:\/\/[^/?#@]+\/?$/i, {
54
+ message: "an http(s) proxy origin without credentials or path; credentials go in auth"
55
+ }));
56
+ /**
57
+ * An explicit HTTP CONNECT proxy for one side, with the hosts that bypass
58
+ * it (`*.suffix` wildcards). Environment proxy variables are never read.
59
+ */
60
+ export class ProxyConfig extends Schema.Class("ProxyConfig")({
61
+ url: ProxyUrl,
62
+ noProxy: Schema.optionalKey(Schema.Array(Schema.String)),
63
+ auth: Schema.optionalKey(BasicAuth)
64
+ }) {
65
+ }
66
+ export const ProxySetting = Schema.Union([Schema.Literal("none"), ProxyConfig]);
43
67
  export class HttpSide extends Schema.Class("HttpSide")({
44
68
  auth: Schema.optionalKey(HttpAuth),
45
- tls: Schema.optionalKey(TlsConfig)
69
+ tls: Schema.optionalKey(TlsConfig),
70
+ proxy: Schema.optionalKey(ProxySetting)
71
+ }) {
72
+ }
73
+ /** The credential the STS request template is filled with. */
74
+ export class StsCredential extends Schema.Class("StsCredential")({
75
+ user: Principal,
76
+ password: SecretRef
77
+ }) {
78
+ }
79
+ /**
80
+ * A SAML bearer token from an STS: the template under the service folder
81
+ * is filled and posted to `endpoint` with this side's auth, TLS and proxy;
82
+ * the first element named `token.element` in the answer is carried, bytes
83
+ * unchanged, in every call's WS-Security header.
84
+ */
85
+ export class StsConfig extends Schema.Class("StsConfig")({
86
+ endpoint: Schema.String.check(Schema.isPattern(/^https?:\/\/[^/?#@]+(?:[/?#][^@]*)?$/i, {
87
+ message: "an http(s) URL without credentials; secrets go in references"
88
+ })),
89
+ soapVersion: Schema.optionalKey(Schema.Literals(["1.1", "1.2"])),
90
+ /** Relative to the service folder; `sts init` writes the default. */
91
+ template: Schema.optionalKey(Schema.String),
92
+ auth: StsCredential,
93
+ appliesTo: Schema.optionalKey(Schema.String),
94
+ token: Schema.optionalKey(Schema.Struct({ element: Schema.optionalKey(Schema.String) })),
95
+ renewBeforeSeconds: Schema.optionalKey(Schema.Int),
96
+ timestamp: Schema.optionalKey(Schema.Boolean),
97
+ tls: Schema.optionalKey(TlsConfig),
98
+ proxy: Schema.optionalKey(ProxySetting),
99
+ timeoutSeconds: Schema.optionalKey(Schema.Int)
46
100
  }) {
47
101
  }
102
+ export const defaultStsTemplate = "sts.request.xml";
103
+ export const defaultTokenElement = "Assertion";
48
104
  export class UsernameToken extends Schema.Class("UsernameToken")({
49
105
  user: Principal,
50
106
  password: SecretRef,
@@ -69,10 +125,14 @@ export class AuthProfile extends Schema.Class("AuthProfile")({
69
125
  /** Calling operations. */
70
126
  call: Schema.optionalKey(HttpSide),
71
127
  wsSecurity: Schema.optionalKey(UsernameToken),
128
+ /** A SAML bearer token from an STS, carried by every call. */
129
+ sts: Schema.optionalKey(StsConfig),
72
130
  mutating: Schema.optionalKey(MutatingPolicies),
73
131
  timeoutSeconds: Schema.optionalKey(Schema.Int)
74
132
  }) {
75
133
  }
134
+ /** `auth.json`, or `auth.<env>.json` when an environment is selected. */
135
+ export const profileFileName = (env) => env === undefined || env.trim() === "" ? "auth.json" : `auth.${env.trim()}.json`;
76
136
  export const defaultMutatingPolicy = (environment) => environment === "dev" ? "confirm" : "flag";
77
137
  export const mutatingPolicy = (profile) => profile.mutating?.[profile.environment] ?? defaultMutatingPolicy(profile.environment);
78
138
  export class AuthProfileError extends Schema.TaggedError()("AuthProfileError", {
@@ -84,11 +144,11 @@ export class AuthProfileError extends Schema.TaggedError()("AuthProfileError", {
84
144
  return `auth profile: ${this.subject}: ${this.detail}`;
85
145
  }
86
146
  }
87
- export const decodeAuthProfile = (text) => Schema.decodeUnknownEffect(Schema.fromJsonString(AuthProfile))(text).pipe(Effect.mapError((error) =>
147
+ export const decodeAuthProfile = (text, file = "auth.json") => Schema.decodeUnknownEffect(Schema.fromJsonString(AuthProfile))(text).pipe(Effect.mapError((error) =>
88
148
  // Schema issues quote the offending input; a mistyped secret could be
89
149
  // in it, so only the paths of the issue are kept.
90
150
  new AuthProfileError({
91
- subject: "auth.json",
151
+ subject: file,
92
152
  detail: `invalid profile (${issuePaths(error.message)})`
93
153
  })));
94
154
  /** Keep only the `at ["a"]["b"]` locations of a schema message, dropping any quoted value. */
@@ -137,27 +197,81 @@ export const resolveTls = (source, tls) => tls === undefined
137
197
  const cert = yield* optionalSecret(source, "cert", tls.cert);
138
198
  const key = yield* optionalSecret(source, "key", tls.key);
139
199
  const pfx = yield* optionalSecret(source, "pfx", tls.pfx);
140
- const ca = yield* optionalSecret(source, "ca", tls.ca);
200
+ const caFile = tls.ca === undefined ? undefined : Redacted.value(yield* resolveSecret(source, tls.ca));
201
+ let caDir;
202
+ if (tls.caDir !== undefined) {
203
+ if (source.readPemDirectory === undefined) {
204
+ return yield* new AuthProfileError({
205
+ subject: "tls.caDir",
206
+ detail: "this source cannot read a directory"
207
+ });
208
+ }
209
+ caDir = yield* source.readPemDirectory(tls.caDir);
210
+ }
211
+ const ca = caFile === undefined && caDir === undefined
212
+ ? {}
213
+ : { ca: Redacted.make(concatPem(caFile, caDir)) };
141
214
  const passphrase = tls.passphrase === undefined
142
215
  ? {}
143
216
  : { passphrase: yield* resolveText(source, tls.passphrase) };
144
- return { ...cert, ...key, ...pfx, ...ca, ...passphrase };
217
+ return {
218
+ ...cert,
219
+ ...key,
220
+ ...pfx,
221
+ ...ca,
222
+ ...passphrase,
223
+ ...(tls.trust === undefined ? {} : { trust: tls.trust }),
224
+ ...(tls.servername === undefined ? {} : { servername: tls.servername })
225
+ };
226
+ });
227
+ const concatPem = (...parts) => {
228
+ const present = parts.filter((part) => part !== undefined);
229
+ const joined = present.map((part) => new TextDecoder().decode(part).trimEnd()).join("\n");
230
+ return new TextEncoder().encode(`${joined}\n`);
231
+ };
232
+ /** Whether a host bypasses the proxy: exact match, or `*.suffix` / `.suffix`. */
233
+ export const bypassesProxy = (host, noProxy) => {
234
+ const lower = host.toLowerCase();
235
+ return noProxy.some((entry) => {
236
+ const pattern = entry.trim().toLowerCase();
237
+ if (pattern === "")
238
+ return false;
239
+ if (pattern === "*")
240
+ return true;
241
+ if (pattern.startsWith("*.") || pattern.startsWith(".")) {
242
+ const suffix = pattern.replace(/^\*/, "");
243
+ return lower.endsWith(suffix) && lower.length > suffix.length;
244
+ }
245
+ return lower === pattern;
246
+ });
247
+ };
248
+ export const resolveProxy = (source, proxy) => proxy === undefined || proxy === "none"
249
+ ? Effect.succeed(undefined)
250
+ : Effect.gen(function* () {
251
+ const authorization = proxy.auth === undefined
252
+ ? {}
253
+ : {
254
+ authorization: basicHeader(yield* resolvePrincipal(source, proxy.auth.user), yield* resolveText(source, proxy.auth.password))
255
+ };
256
+ return { url: proxy.url, noProxy: proxy.noProxy ?? [], ...authorization };
145
257
  });
146
258
  const basicHeader = (user, password) => Redacted.make(`Basic ${Buffer.from(`${user}:${Redacted.value(password)}`, "utf8").toString("base64")}`);
147
259
  export const resolveSide = (source, side) => Effect.gen(function* () {
148
260
  const auth = side?.auth;
149
261
  const tls = yield* resolveTls(source, side?.tls);
262
+ const proxy = yield* resolveProxy(source, side?.proxy);
150
263
  if (auth === undefined || auth.scheme === "none")
151
- return { headers: [], tls };
264
+ return { headers: [], tls, proxy };
152
265
  if (auth.scheme === "basic") {
153
266
  const user = yield* resolvePrincipal(source, auth.user);
154
267
  const password = yield* resolveText(source, auth.password);
155
- return { headers: [["authorization", basicHeader(user, password)]], tls };
268
+ return { headers: [["authorization", basicHeader(user, password)]], tls, proxy };
156
269
  }
157
270
  const token = yield* resolveText(source, auth.token);
158
271
  return {
159
272
  headers: [["authorization", Redacted.make(`Bearer ${Redacted.value(token)}`)]],
160
- tls
273
+ tls,
274
+ proxy
161
275
  };
162
276
  });
163
277
  export const resolveUsernameToken = (source, token) => token === undefined
@@ -175,5 +289,13 @@ export const nodeSecretSource = (environment = process.env) => ({
175
289
  readFile: (path) => Effect.tryPromise({
176
290
  try: async () => new Uint8Array(await readFile(path)),
177
291
  catch: () => new AuthProfileError({ subject: `file:${path}`, detail: "not readable" })
292
+ }),
293
+ readPemDirectory: (path) => Effect.tryPromise({
294
+ try: async () => {
295
+ const names = (await readdir(path)).filter((name) => /\.(pem|crt|cer)$/i.test(name)).sort();
296
+ const parts = await Promise.all(names.map((name) => readFile(join(path, name))));
297
+ return concatPem(...parts.map((part) => new Uint8Array(part)));
298
+ },
299
+ catch: () => new AuthProfileError({ subject: `tls.caDir ${path}`, detail: "not readable" })
178
300
  })
179
301
  });
@@ -7,7 +7,7 @@ import { resolveSide, resolveUsernameToken } from "./Auth.js";
7
7
  import { checkCall } from "./CallPolicy.js";
8
8
  import { elementByName, operationByName } from "./Catalog.js";
9
9
  import { effectiveClass } from "./Classification.js";
10
- import { buildEnvelope, readEnvelope, safeHeaders, soapHeaders, stripSecurity, usernameTokenHeader } from "./Envelope.js";
10
+ import { buildEnvelope, readEnvelope, safeHeaders, soapHeaders, securityHeader, stripSecurity } from "./Envelope.js";
11
11
  import { instanceFromXml, instanceToXml, validateInstance } from "./Instance.js";
12
12
  import { kindsFromCatalog, maskDocument, MaskingReport, mergeReports } from "./Masking.js";
13
13
  import { Exchange, issueRecords, loadMaskingKey, loadMaskingOverrides, readRequestFile, RecordedMessage, RecordedResponse, samplePaths, writeExchange } from "./Samples.js";
@@ -92,12 +92,23 @@ export const callOperation = (options) => Effect.gen(function* () {
92
92
  });
93
93
  }
94
94
  const side = yield* resolveSide(options.secrets, profile.call);
95
- const token = yield* resolveUsernameToken(options.secrets, profile.wsSecurity);
95
+ const usernameToken = yield* resolveUsernameToken(options.secrets, profile.wsSecurity);
96
96
  const now = options.now ?? (() => new Date());
97
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() });
98
+ if (profile.sts !== undefined && options.token === undefined) {
99
+ return yield* new CallSetupError({
100
+ operation: operation.name,
101
+ detail: "the profile names an STS but the call has no token source"
102
+ });
103
+ }
104
+ const saml = options.token === undefined ? undefined : yield* options.token.current;
105
+ const created = now().toISOString();
106
+ const header = securityHeader({
107
+ ...(usernameToken === undefined ? {} : { usernameToken }),
108
+ ...(saml === undefined ? {} : { token: saml.token }),
109
+ ...(profile.sts?.timestamp === false || saml === undefined ? {} : { timestamp: { created } }),
110
+ material: { nonce: random(16), created }
111
+ });
101
112
  const bodyXml = instanceToXml(catalog, input, request.body);
102
113
  const envelope = buildEnvelope(operation.soapVersion, bodyXml, header);
103
114
  const headers = soapHeaders(operation.soapVersion, operation.soapAction);
@@ -108,6 +119,7 @@ export const callOperation = (options) => Effect.gen(function* () {
108
119
  secretHeaders: side.headers,
109
120
  body: envelope,
110
121
  ...(side.tls === undefined ? {} : { tls: side.tls }),
122
+ ...(side.proxy === undefined ? {} : { proxy: side.proxy }),
111
123
  timeout: Duration.seconds(profile.timeoutSeconds ?? 60)
112
124
  });
113
125
  const key = yield* loadMaskingKey(workspace, service, random);
@@ -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