@llm4ts/shell 2.12.0 → 2.13.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.
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 +283 -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 +1339 -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 +455 -0
  42. package/kits/soap-ace/flows/lib/soap/Xsd.js +720 -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,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
+ };