@bridge_gpt/mcp-server 0.2.38 → 0.2.39
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/README.md +189 -14
- package/build/agent-capabilities/probe-context.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +392 -0
- package/build/agent-launchers/executor-adapter-inspection.js +163 -0
- package/build/agent-launchers/executor-adapter-registry.js +90 -0
- package/build/agent-launchers/executor-adapter.js +136 -0
- package/build/agent-registry.js +28 -0
- package/build/agents.generated.js +1 -1
- package/build/claude-login.js +85 -0
- package/build/claude-user-config-doctor.js +59 -33
- package/build/commands.generated.js +12 -11
- package/build/conduct-epic/bridge-client.js +345 -0
- package/build/conduct-epic/checkpoint-store.js +423 -0
- package/build/conduct-epic/cli.js +1732 -0
- package/build/conduct-epic/lock.js +302 -0
- package/build/conduct-epic/pr-state.js +197 -0
- package/build/conduct-epic/spawn.js +101 -0
- package/build/conductor/bridge-api-client.js +37 -2
- package/build/conductor/doctor.js +11 -1
- package/build/conductor/install-doctor.js +184 -10
- package/build/conductor-bin.js +7 -7
- package/build/credential-store.js +10 -4
- package/build/credentials-cli.js +34 -19
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +579 -88
- package/build/executor/agent-identity.js +32 -0
- package/build/executor/cli.js +50 -39
- package/build/executor/deps.js +15 -1
- package/build/executor/env.js +56 -45
- package/build/executor/index.js +9 -1
- package/build/executor/install-preflight.js +138 -0
- package/build/executor/job-errors.js +200 -0
- package/build/executor/job-runner.js +619 -268
- package/build/executor/observation.js +165 -0
- package/build/executor/permissions.js +163 -36
- package/build/executor/platform.js +54 -0
- package/build/executor/preflight.js +175 -67
- package/build/executor/process.js +39 -7
- package/build/executor/runner.js +19 -0
- package/build/executor/service-lifecycle.js +269 -0
- package/build/executor/service-unit.js +121 -12
- package/build/executor/stale-artifacts.js +70 -0
- package/build/executor/test-clock.js +188 -24
- package/build/executor/worker-command.js +22 -58
- package/build/executor/worker-log.js +82 -0
- package/build/executor/worktree-lock.js +264 -0
- package/build/index.js +527 -357
- package/build/install-bridge-conductor.js +376 -38
- package/build/install-bridge.js +414 -114
- package/build/install-doctor.js +13 -0
- package/build/install-reexec.js +5 -3
- package/build/mcp-install-state.js +130 -0
- package/build/mcp-profile.js +11 -2
- package/build/mcp-provisioning.js +15 -0
- package/build/merge-pull-request.js +562 -0
- package/build/phase-result-artifacts.js +450 -0
- package/build/pipeline-orchestrator.js +4 -0
- package/build/pipeline-utils.js +16 -0
- package/build/pipelines.generated.js +7 -7
- package/build/plane/preflight.js +18 -14
- package/build/plane/supervisor.js +8 -1
- package/build/project-root.js +34 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +36 -9
- package/build/setup-epic.js +57 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/reads-site-preference.js +6 -0
- package/build/sfcc/register.js +61 -23
- package/build/sfcc/registration-inventory.js +89 -0
- package/build/sfcc/setup-status.js +18 -34
- package/build/sfcc/tool-wrapper.js +294 -17
- package/build/sfcc/write-grants.js +33 -1
- package/build/sfcc/write-guard.js +41 -12
- package/build/sfcc/writes-custom-object-def.js +6 -2
- package/build/sfcc/writes-site-preference.js +6 -1
- package/build/sfcc/writes-system-object.js +11 -2
- package/build/sfcc/writes.js +13 -8
- package/build/start-tickets-prereqs.js +25 -15
- package/build/start-tickets.js +123 -21
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +9 -3
- package/docs/install/mcp-tool-integrations.md +54 -9
- package/docs/install/sfcc-integration.md +71 -24
- package/package.json +3 -3
- package/build/executor/worker-config-isolation.js +0 -287
|
@@ -1,19 +1,290 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The SFCC target boundary and the call-time SFCC configuration gate.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* This module owns two distinct, layered checks. They run in this order, and
|
|
5
|
+
* the order is the whole point:
|
|
6
|
+
*
|
|
7
|
+
* 1. `withSfccTargetBoundary` — WHICH INSTANCE are we about to contact?
|
|
8
|
+
* Resolves credentials through the real precedence chain and validates the
|
|
9
|
+
* hostname those credentials actually select. Fails closed.
|
|
10
|
+
* 2. `withSfccGate` — is this repo CONFIGURED for SFCC at all? Reads the
|
|
11
|
+
* Bridge `version` config field and consumes the already-approved target
|
|
12
|
+
* rather than resolving a second time.
|
|
7
13
|
*
|
|
8
14
|
* The "not configured" response distinguishes three failure classes:
|
|
9
15
|
* (a) Bridge-auth/config failure — could not read /config-field/version.
|
|
10
16
|
* (b) version-not-SFCC — version is set but is not an SFCC version.
|
|
11
17
|
* (c) missing/ambiguous dw.json — credential resolution failed.
|
|
18
|
+
*
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* DECISION 1(a) — validate the RESOLVED hostname (BAPI-817)
|
|
21
|
+
* ---------------------------------------------------------------------------
|
|
22
|
+
*
|
|
23
|
+
* The README promised in five places that "non-sandbox instances are rejected".
|
|
24
|
+
* Before BAPI-817 nothing validated the target host. The only two guards
|
|
25
|
+
* (`write-guard.ts`, `reads-site-preference.ts`) compared a CALLER-SUPPLIED
|
|
26
|
+
* string argument to the literal `"sandbox"` — and `write-guard.ts` defaulted an
|
|
27
|
+
* omitted `instance` to `"sandbox"`, so the guard passed on its single most
|
|
28
|
+
* common call shape while the request went to whatever host `dw.json` named.
|
|
29
|
+
* Point `dw.json` or `SFCC_HOSTNAME` at production and every read and all nine
|
|
30
|
+
* writes executed against it.
|
|
31
|
+
*
|
|
32
|
+
* BAPI-817 resolves the ticket's Decision 1 as **(a): validate the actual
|
|
33
|
+
* resolved target hostname**, not (b) per-instance opt-in. (a) is the fix that
|
|
34
|
+
* makes the existing documentation true rather than lowering the promise to
|
|
35
|
+
* match the code. Two properties make it load-bearing:
|
|
36
|
+
*
|
|
37
|
+
* - **It validates `credentials.hostname`**, the value the OCAPI client will
|
|
38
|
+
* literally put in the URL — never `args.instance`, never `SFCC_HOSTNAME`,
|
|
39
|
+
* never a raw `dw.json` field read independently. Every credential
|
|
40
|
+
* precedence tier converges on one resolved host, so validating that one
|
|
41
|
+
* value covers all of them. A check on any input *upstream* of resolution
|
|
42
|
+
* can be contradicted by a later tier; a check on the output cannot.
|
|
43
|
+
* - **It fails closed.** An unrecognized, malformed, or unparseable hostname
|
|
44
|
+
* is refused. The accepted forms are an anchored allowlist built only from
|
|
45
|
+
* hostname forms this repository actually evidences (see
|
|
46
|
+
* `APPROVED_SANDBOX_HOST_FORMS`); a substring test such as
|
|
47
|
+
* `hostname.includes(".sandbox.")` is NOT used, because it also matches
|
|
48
|
+
* hosts like `prod.sandbox.attacker.example` where the label is not in the
|
|
49
|
+
* position the form requires.
|
|
50
|
+
*
|
|
51
|
+
* Caller-supplied context can no longer authorize a target. Omitting `instance`
|
|
52
|
+
* and passing `instance: "sandbox"` are now equivalent to passing nothing: they
|
|
53
|
+
* do not enter the decision. A dotted `instance` still *selects* a host through
|
|
54
|
+
* the existing explicit-override precedence, but the host it selects is then
|
|
55
|
+
* validated like any other, so `check_permissions` can no longer be aimed at an
|
|
56
|
+
* arbitrary named production instance.
|
|
57
|
+
*
|
|
58
|
+
* `register.ts` makes this structurally unavoidable: it wraps the host
|
|
59
|
+
* registrar once, and every SFCC tool — the always-on diagnostics, the eight
|
|
60
|
+
* reads (including the five that have no `instance` parameter at all), the nine
|
|
61
|
+
* writes, and `sfcc_log_query` — is registered through that adapter. There is
|
|
62
|
+
* no per-tool opt-in and no exemption flag.
|
|
12
63
|
*/
|
|
13
64
|
import { SFCC_VERSIONS, getSfccVersionConfig } from "./config.js";
|
|
14
65
|
import { resolveSfccCredentials } from "./credentials.js";
|
|
15
66
|
import { formatSfccFailure } from "./sfcc-result.js";
|
|
16
67
|
// ---------------------------------------------------------------------------
|
|
68
|
+
// Hostname policy
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/**
|
|
71
|
+
* The DNS suffix every approved sandbox form shares.
|
|
72
|
+
*
|
|
73
|
+
* Every SFCC developer-sandbox hostname evidenced anywhere in this repository —
|
|
74
|
+
* `credentials.ts`'s JSDoc, the install guide's prerequisites, the BAPI-399
|
|
75
|
+
* live smoke-test record, and the existing test fixtures — lives under this
|
|
76
|
+
* suffix. Legacy `*.demandware.net` hosts are deliberately NOT accepted: that
|
|
77
|
+
* domain covers sandbox, staging, development, and production instances alike
|
|
78
|
+
* with no suffix that separates them, so admitting it would reintroduce exactly
|
|
79
|
+
* the unbounded target this boundary exists to close. It appears in this repo
|
|
80
|
+
* only as a test fixture, never in production code or the SFCC install docs.
|
|
81
|
+
*/
|
|
82
|
+
const SFCC_DX_SUFFIX = ".dx.commercecloud.salesforce.com";
|
|
83
|
+
/**
|
|
84
|
+
* First labels that name a non-sandbox environment on the B2C platform.
|
|
85
|
+
*
|
|
86
|
+
* Checked against the leading label of every candidate, so a host like
|
|
87
|
+
* `production-zzrf.dx.commercecloud.salesforce.com` or
|
|
88
|
+
* `staging-zzrf.sandbox.us01.dx.commercecloud.salesforce.com` is refused even
|
|
89
|
+
* though it would otherwise satisfy a form below. This is belt-and-braces: form
|
|
90
|
+
* B alone would already admit `production-001`, and a named production instance
|
|
91
|
+
* is precisely the target the ticket was filed about.
|
|
92
|
+
*/
|
|
93
|
+
const RESERVED_ENVIRONMENT_LABELS = [
|
|
94
|
+
"production",
|
|
95
|
+
"prod",
|
|
96
|
+
"staging",
|
|
97
|
+
"stg",
|
|
98
|
+
"development",
|
|
99
|
+
];
|
|
100
|
+
/**
|
|
101
|
+
* Human-readable description of every accepted form, for the refusal envelope
|
|
102
|
+
* and the documentation contract test. Keep in sync with
|
|
103
|
+
* `isApprovedSfccSandboxHost` — the tests assert both directions.
|
|
104
|
+
*/
|
|
105
|
+
export const APPROVED_SANDBOX_HOST_FORMS = [
|
|
106
|
+
"<realm>-<nnn>.sandbox.<region>.dx.commercecloud.salesforce.com",
|
|
107
|
+
"<realm>-<nnn>.sandbox.dx.commercecloud.salesforce.com",
|
|
108
|
+
"<realm>-<nnn>.dx.commercecloud.salesforce.com",
|
|
109
|
+
];
|
|
110
|
+
/** One DNS label: 1–63 chars, alphanumeric, internal hyphens only. */
|
|
111
|
+
const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
112
|
+
/** Form B's leading label: an SFCC realm id followed by a numeric instance. */
|
|
113
|
+
const REALM_INSTANCE_LABEL = /^[a-z0-9]+-\d{3}$/;
|
|
114
|
+
/**
|
|
115
|
+
* Reduce a raw hostname to a canonical comparable form, or `null` if it is not
|
|
116
|
+
* a bare hostname at all.
|
|
117
|
+
*
|
|
118
|
+
* Only DNS-equivalent normalization is applied — lowercasing and stripping a
|
|
119
|
+
* single trailing root dot. Everything else is a rejection rather than a
|
|
120
|
+
* repair: a value carrying a scheme, port, path, query, credentials, or
|
|
121
|
+
* whitespace is not a hostname, and "cleaning it up" would mean guessing which
|
|
122
|
+
* part the operator meant. Guessing is how a boundary gets bypassed, so this
|
|
123
|
+
* returns `null` and the caller fails closed.
|
|
124
|
+
*/
|
|
125
|
+
export function canonicalizeSfccHostname(raw) {
|
|
126
|
+
if (typeof raw !== "string")
|
|
127
|
+
return null;
|
|
128
|
+
let value = raw.trim().toLowerCase();
|
|
129
|
+
if (value.endsWith("."))
|
|
130
|
+
value = value.slice(0, -1);
|
|
131
|
+
if (value === "" || value.length > 253)
|
|
132
|
+
return null;
|
|
133
|
+
// Anything that is not strictly `label(.label)*` — scheme, port, path, query,
|
|
134
|
+
// userinfo, whitespace, IPv6 brackets, wildcards.
|
|
135
|
+
if (!/^[a-z0-9.-]+$/.test(value))
|
|
136
|
+
return null;
|
|
137
|
+
if (value.includes(".."))
|
|
138
|
+
return null;
|
|
139
|
+
const labels = value.split(".");
|
|
140
|
+
if (labels.length < 2)
|
|
141
|
+
return null;
|
|
142
|
+
if (!labels.every((label) => DNS_LABEL.test(label)))
|
|
143
|
+
return null;
|
|
144
|
+
// A bare IPv4 address is not a sandbox hostname and must not be canonicalized
|
|
145
|
+
// into one; the suffix check below would reject it anyway, but rejecting here
|
|
146
|
+
// keeps "is this a hostname?" and "is this host approved?" separate concerns.
|
|
147
|
+
if (/^\d+(\.\d+)*$/.test(value))
|
|
148
|
+
return null;
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Decide whether a canonical hostname is an approved developer sandbox.
|
|
153
|
+
*
|
|
154
|
+
* Matching is ANCHORED on both ends: the host must END with the platform suffix
|
|
155
|
+
* and the labels in front of it must form one of the shapes below. This is the
|
|
156
|
+
* property a substring test lacks — `.includes(".sandbox.")` matches
|
|
157
|
+
* `zzzz-001.sandbox.evil.example.com`, whose registrable domain is not
|
|
158
|
+
* Salesforce's at all.
|
|
159
|
+
*
|
|
160
|
+
* Form A — explicit sandbox label, with or without a region:
|
|
161
|
+
* `zzzz-001.sandbox.us01.dx.commercecloud.salesforce.com`
|
|
162
|
+
* `zzzz-001.sandbox.dx.commercecloud.salesforce.com`
|
|
163
|
+
* Form B — realm + numeric instance, the on-demand sandbox form this project
|
|
164
|
+
* actually smoke-tested against (`docs/BAPI-399-sfcc-mcp-smoke-test.md`
|
|
165
|
+
* records sandbox `zyis-001`):
|
|
166
|
+
* `zyis-001.dx.commercecloud.salesforce.com`
|
|
167
|
+
*
|
|
168
|
+
* Form B is required for correctness, not convenience: without it the boundary
|
|
169
|
+
* would refuse the very sandbox this repository documents itself using, and a
|
|
170
|
+
* safety check that blocks the intended workflow gets disabled rather than
|
|
171
|
+
* obeyed. It is kept safe by requiring the `<realm>-<nnn>` shape (named
|
|
172
|
+
* environments such as `production-zzrf` do not match) and by the reserved-label
|
|
173
|
+
* check.
|
|
174
|
+
*
|
|
175
|
+
* Anything not matching — including every `*.demandware.net` host and any other
|
|
176
|
+
* registrable domain — is refused. Widening this set is a deliberate decision,
|
|
177
|
+
* not a bug fix: the ticket's Decision 1(b), an explicit per-instance opt-in, is
|
|
178
|
+
* the sanctioned way to reach an instance this policy does not recognize.
|
|
179
|
+
*/
|
|
180
|
+
export function isApprovedSfccSandboxHost(canonical) {
|
|
181
|
+
if (!canonical.endsWith(SFCC_DX_SUFFIX))
|
|
182
|
+
return false;
|
|
183
|
+
const prefix = canonical.slice(0, -SFCC_DX_SUFFIX.length);
|
|
184
|
+
if (prefix === "")
|
|
185
|
+
return false;
|
|
186
|
+
const labels = prefix.split(".");
|
|
187
|
+
const first = labels[0];
|
|
188
|
+
for (const reserved of RESERVED_ENVIRONMENT_LABELS) {
|
|
189
|
+
if (first === reserved || first.startsWith(`${reserved}-`))
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
// Form A: <label>.sandbox[.<region>]
|
|
193
|
+
if ((labels.length === 2 || labels.length === 3) && labels[1] === "sandbox")
|
|
194
|
+
return true;
|
|
195
|
+
// Form B: <realm>-<nnn>
|
|
196
|
+
if (labels.length === 1 && REALM_INSTANCE_LABEL.test(first))
|
|
197
|
+
return true;
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The one refusal envelope every SFCC tool returns for an unapproved target.
|
|
202
|
+
*
|
|
203
|
+
* Built through the shared `formatSfccFailure` so it is detectable by
|
|
204
|
+
* `result.isError === true` and parseable by the single SFCC error-parse path,
|
|
205
|
+
* exactly like every other SFCC failure.
|
|
206
|
+
*
|
|
207
|
+
* `403` is the honest status: Bridge understood the request and refused to
|
|
208
|
+
* perform it. `source: "gate"` is likewise honest — this is Bridge's own
|
|
209
|
+
* decision, not something OCAPI or WebDAV returned, so no upstream status is
|
|
210
|
+
* supplied.
|
|
211
|
+
*
|
|
212
|
+
* Only the rejected hostname and the accepted forms appear in the body. The
|
|
213
|
+
* hostname is not a secret and the operator cannot fix the problem without
|
|
214
|
+
* seeing it; the credential source string, client id, client secret, token, and
|
|
215
|
+
* raw `dw.json` contents never enter this envelope.
|
|
216
|
+
*/
|
|
217
|
+
function targetNotSandbox(rejectedHostname) {
|
|
218
|
+
// A malformed value is echoed back bounded, so a pathological configuration
|
|
219
|
+
// value cannot inflate the response.
|
|
220
|
+
const safeRejected = rejectedHostname.length > 120 ? `${rejectedHostname.slice(0, 120)}…` : rejectedHostname;
|
|
221
|
+
return formatSfccFailure({
|
|
222
|
+
code: "TARGET_NOT_SANDBOX",
|
|
223
|
+
status: 403,
|
|
224
|
+
message: `SFCC tools are restricted to a developer sandbox. Refusing to contact ` +
|
|
225
|
+
`'${safeRejected}', which is not a recognized sandbox instance. Point dw.json ` +
|
|
226
|
+
`or SFCC_HOSTNAME at a developer sandbox and retry.`,
|
|
227
|
+
source: "gate",
|
|
228
|
+
details: {
|
|
229
|
+
failure_class: "target-not-sandbox",
|
|
230
|
+
rejected_hostname: safeRejected,
|
|
231
|
+
approved_hostname_forms: APPROVED_SANDBOX_HOST_FORMS,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Target boundary
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
/**
|
|
239
|
+
* Wrap an SFCC handler with the resolved-hostname sandbox boundary.
|
|
240
|
+
*
|
|
241
|
+
* `register.ts` applies this to EVERY SFCC tool by wrapping the host registrar
|
|
242
|
+
* once, so no tool can be added that skips it — the alternative, asking each
|
|
243
|
+
* handler to remember a check, is what produced the five reads that had no
|
|
244
|
+
* `instance` parameter and therefore no guard at all.
|
|
245
|
+
*
|
|
246
|
+
* The returned handler:
|
|
247
|
+
* 1. Derives an explicit hostname override from `args.instance`, preserving
|
|
248
|
+
* the existing "a hostname always contains a dot, the OCAPI instance-context
|
|
249
|
+
* enum never does" rule.
|
|
250
|
+
* 2. Resolves credentials through the real precedence chain.
|
|
251
|
+
* 3. Refuses when the RESOLVED hostname is not an approved sandbox.
|
|
252
|
+
* 4. Otherwise calls the handler with the approved target.
|
|
253
|
+
*
|
|
254
|
+
* A resolution failure is passed through rather than refused — see
|
|
255
|
+
* `SfccTargetContext`. The refusal is returned as a normal MCP result and never
|
|
256
|
+
* thrown, so it cannot escape to the stdio transport.
|
|
257
|
+
*/
|
|
258
|
+
export function withSfccTargetBoundary(handler, deps = {}) {
|
|
259
|
+
const resolve = deps.resolveCredentials ?? resolveSfccCredentials;
|
|
260
|
+
return async (args) => {
|
|
261
|
+
const safeArgs = args ?? {};
|
|
262
|
+
// A hostname always contains a dot; the OCAPI instance-context enum
|
|
263
|
+
// (e.g. "sandbox") never does — only treat args.instance as an explicit
|
|
264
|
+
// hostname override when it looks like one. The override still only
|
|
265
|
+
// *selects* a candidate; approval below is based on what resolution
|
|
266
|
+
// actually returned.
|
|
267
|
+
const rawInstance = typeof safeArgs.instance === "string" ? safeArgs.instance : undefined;
|
|
268
|
+
const explicitHostname = rawInstance && rawInstance.includes(".") ? rawInstance : undefined;
|
|
269
|
+
const credResult = await resolve(explicitHostname);
|
|
270
|
+
if (!credResult.ok) {
|
|
271
|
+
return handler(safeArgs, { approved: false, error: credResult.error });
|
|
272
|
+
}
|
|
273
|
+
const rawHostname = credResult.credentials.hostname;
|
|
274
|
+
const canonical = canonicalizeSfccHostname(rawHostname);
|
|
275
|
+
if (canonical === null || !isApprovedSfccSandboxHost(canonical)) {
|
|
276
|
+
return targetNotSandbox(typeof rawHostname === "string" ? rawHostname : String(rawHostname));
|
|
277
|
+
}
|
|
278
|
+
return handler(safeArgs, {
|
|
279
|
+
approved: true,
|
|
280
|
+
// The canonical host is substituted so every downstream URL is built from
|
|
281
|
+
// the exact value that was approved.
|
|
282
|
+
credentials: { ...credResult.credentials, hostname: canonical },
|
|
283
|
+
hostname: canonical,
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
17
288
|
// Not-configured envelope helpers
|
|
18
289
|
// ---------------------------------------------------------------------------
|
|
19
290
|
/**
|
|
@@ -71,14 +342,24 @@ function notConfigured(failureClass, message) {
|
|
|
71
342
|
* The returned handler:
|
|
72
343
|
* 1. Reads /config-field/version via Bridge API.
|
|
73
344
|
* 2. Validates the value is an SFCC version.
|
|
74
|
-
* 3.
|
|
345
|
+
* 3. Consumes the credentials the target boundary already approved.
|
|
75
346
|
* 4. Calls the inner handler only if all checks pass.
|
|
76
347
|
*
|
|
348
|
+
* **This gate no longer resolves credentials itself** (BAPI-817). It receives an
|
|
349
|
+
* `SfccTargetContext` that `withSfccTargetBoundary` produced, and uses the
|
|
350
|
+
* credentials in it. Resolving a second time here would let the gate hand a
|
|
351
|
+
* handler a *different* host than the one that was approved — a
|
|
352
|
+
* time-of-check/time-of-use split in the middle of the boundary this ticket
|
|
353
|
+
* exists to establish. `missing-dw-json` is still this gate's own failure class
|
|
354
|
+
* and keeps its exact prior envelope; it is now driven by the unresolved target
|
|
355
|
+
* context rather than by a duplicate resolution call.
|
|
356
|
+
*
|
|
77
357
|
* Every early rejection is the unified `isError: true` result — unconditionally,
|
|
78
358
|
* with no tool-name check, per-tool branch, exemption flag, or gate-mode
|
|
79
359
|
* parameter. `sfcc_setup_status`, the one SFCC tool whose successful output *is*
|
|
80
360
|
* a not-configured report, is exempt structurally: `register.ts` registers it
|
|
81
|
-
* outside this gate, so no code here
|
|
361
|
+
* outside this gate (though still inside the target boundary), so no code here
|
|
362
|
+
* needs to know about it.
|
|
82
363
|
*
|
|
83
364
|
* On the success path the inner handler's result is returned directly — not
|
|
84
365
|
* cloned, wrapped, or re-formatted — so write handlers keep ownership of their
|
|
@@ -86,7 +367,7 @@ function notConfigured(failureClass, message) {
|
|
|
86
367
|
* type through the gate.
|
|
87
368
|
*/
|
|
88
369
|
export function withSfccGate(deps, handler) {
|
|
89
|
-
return async (args) => {
|
|
370
|
+
return async (args, target) => {
|
|
90
371
|
// (a) Read /config-field/version
|
|
91
372
|
const version = await getSfccVersionConfig(deps.buildGetUrl, deps.getGetHeaders, deps.repoName);
|
|
92
373
|
if (version === null) {
|
|
@@ -100,18 +381,14 @@ export function withSfccGate(deps, handler) {
|
|
|
100
381
|
`Expected one of: ${SFCC_VERSIONS.join(", ")}. ` +
|
|
101
382
|
`Update the version field in your Bridge API project settings.`);
|
|
102
383
|
}
|
|
103
|
-
// (c)
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const explicitHostname = rawInstance && rawInstance.includes(".") ? rawInstance : undefined;
|
|
109
|
-
const credResult = await resolveSfccCredentials(explicitHostname);
|
|
110
|
-
if (!credResult.ok) {
|
|
111
|
-
return notConfigured("missing-dw-json", `SFCC credential resolution failed: ${credResult.error} ` +
|
|
384
|
+
// (c) Consume the boundary-approved target. An unapproved *host* never
|
|
385
|
+
// reaches here — the boundary already refused it — so the only remaining
|
|
386
|
+
// case is credential resolution having failed outright.
|
|
387
|
+
if (!target.approved) {
|
|
388
|
+
return notConfigured("missing-dw-json", `SFCC credential resolution failed: ${target.error} ` +
|
|
112
389
|
"Ensure a dw.json file exists in your project root with " +
|
|
113
390
|
"hostname, client-id, and client-secret fields.");
|
|
114
391
|
}
|
|
115
|
-
return handler(args,
|
|
392
|
+
return handler(args, target.credentials);
|
|
116
393
|
};
|
|
117
394
|
}
|
|
@@ -27,6 +27,38 @@ export const OCAPI_WRITE_RESOURCE_IDS = [
|
|
|
27
27
|
"/custom_object_definitions/**",
|
|
28
28
|
"/site_preferences/**",
|
|
29
29
|
];
|
|
30
|
+
/**
|
|
31
|
+
* The exact HTTP methods each resource family needs, derived from the methods
|
|
32
|
+
* the nine shipped write handlers actually call (BAPI-817).
|
|
33
|
+
*
|
|
34
|
+
* Previously every family was granted `["get","put","patch","delete"]`. **No
|
|
35
|
+
* shipped handler performs a DELETE** — the write surface is create/update only
|
|
36
|
+
* (`ocapiPut`, `ocapiPatch`, `ocapiPatchDirect` in `client.ts`; verified across
|
|
37
|
+
* `writes-system-object.ts`, `writes-custom-object-def.ts`, and
|
|
38
|
+
* `writes-site-preference.ts`). Granting `delete` handed an operator's OCAPI
|
|
39
|
+
* client a destructive capability that Bridge cannot even invoke, on a grant
|
|
40
|
+
* they were told to paste once and forget. The narrower set below is what the
|
|
41
|
+
* code demonstrably needs and nothing more.
|
|
42
|
+
*
|
|
43
|
+
* `get` is retained where a write path genuinely reads: `ocapiPatch` performs a
|
|
44
|
+
* GET-then-If-Match ETag round trip before the PATCH, so removing `get` would
|
|
45
|
+
* break every update tool.
|
|
46
|
+
*
|
|
47
|
+
* /system_object_definitions get — the check_permissions probe.
|
|
48
|
+
* /system_object_definitions/** put, patch (+get for ETag) — attribute and
|
|
49
|
+
* attribute-group create/update, assignment,
|
|
50
|
+
* custom preference create.
|
|
51
|
+
* /custom_object_definitions/** put, patch (+get for ETag) — attribute
|
|
52
|
+
* definition create/update.
|
|
53
|
+
* /site_preferences/** patch (+get) — site_preference_values_set,
|
|
54
|
+
* a direct PATCH with no ETag round trip.
|
|
55
|
+
*/
|
|
56
|
+
export const OCAPI_WRITE_RESOURCE_METHODS = {
|
|
57
|
+
"/system_object_definitions": ["get"],
|
|
58
|
+
"/system_object_definitions/**": ["get", "put", "patch"],
|
|
59
|
+
"/custom_object_definitions/**": ["get", "put", "patch"],
|
|
60
|
+
"/site_preferences/**": ["get", "patch"],
|
|
61
|
+
};
|
|
30
62
|
// ---------------------------------------------------------------------------
|
|
31
63
|
// Grant JSON builders
|
|
32
64
|
// ---------------------------------------------------------------------------
|
|
@@ -44,7 +76,7 @@ export function buildOcapiWriteGrantSettings(ocapiVersion, clientIdPlaceholder =
|
|
|
44
76
|
client_id: clientIdPlaceholder,
|
|
45
77
|
resources: OCAPI_WRITE_RESOURCE_IDS.map((resource_id) => ({
|
|
46
78
|
resource_id,
|
|
47
|
-
methods: [
|
|
79
|
+
methods: [...(OCAPI_WRITE_RESOURCE_METHODS[resource_id] ?? [])],
|
|
48
80
|
read_attributes: "(**)",
|
|
49
81
|
write_attributes: "(**)",
|
|
50
82
|
})),
|
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Secondary OCAPI instance-CONTEXT check for writes (BAPI-582 foundation).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* ⚠️ **This is NOT the sandbox security boundary.** It validates a
|
|
5
|
+
* caller-supplied *input string*, not the instance the request will reach. The
|
|
6
|
+
* hostname boundary lives in `tool-wrapper.ts`
|
|
7
|
+
* (`withSfccTargetBoundary` / `isApprovedSfccSandboxHost`), runs at
|
|
8
|
+
* registration level for all twenty SFCC tools, and validates the hostname
|
|
9
|
+
* credential resolution actually selected.
|
|
10
|
+
*
|
|
11
|
+
* The distinction is the entire subject of BAPI-817. This guard was previously
|
|
12
|
+
* described — in the README and in its own comments — as the thing that made
|
|
13
|
+
* SFCC writes sandbox-only. It never could be: `instance` is an argument the
|
|
14
|
+
* caller chooses, the target came from `dw.json`/`SFCC_HOSTNAME`, and the two
|
|
15
|
+
* were never compared. Worse, the guard **defaulted an omitted `instance` to
|
|
16
|
+
* `"sandbox"` and passed**, so it approved its single most common call shape
|
|
17
|
+
* while the request went to whatever host was configured. Nine destructive
|
|
18
|
+
* tools sat behind that.
|
|
19
|
+
*
|
|
20
|
+
* The behavior below is deliberately UNCHANGED, and is kept only as
|
|
21
|
+
* defence-in-depth on the OCAPI instance-context input: it rejects a caller who
|
|
22
|
+
* explicitly asks for `"staging"` or `"production"` context, which is still
|
|
23
|
+
* worth refusing. What changed is what it is allowed to *mean*. Its acceptance
|
|
24
|
+
* no longer authorizes anything — a write reaching this guard has already had
|
|
25
|
+
* its target hostname independently approved, and omitting `instance` cannot
|
|
26
|
+
* bypass that approval because the hostname check does not consult `instance`
|
|
27
|
+
* at all.
|
|
28
|
+
*
|
|
29
|
+
* SFCC write handlers still call `rejectIfNotSandboxForWrite` immediately after
|
|
30
|
+
* input parsing and BEFORE any payload construction or OCAPI mutation. A
|
|
31
|
+
* non-null return value is the pre-formatted MCP rejection to return directly;
|
|
32
|
+
* a null return means the effective instance context is `sandbox` and the
|
|
33
|
+
* handler may proceed.
|
|
9
34
|
*
|
|
10
35
|
* BAPI-766 moved the rejection's *representation* to the shared SFCC formatter
|
|
11
36
|
* so the guardrail fails at the protocol level like every other SFCC failure.
|
|
@@ -17,13 +42,17 @@ import { formatSfccWriteFailure } from "./sfcc-result.js";
|
|
|
17
42
|
// Guard
|
|
18
43
|
// ---------------------------------------------------------------------------
|
|
19
44
|
/**
|
|
20
|
-
* Reject
|
|
45
|
+
* Reject a write whose OCAPI instance CONTEXT is not `sandbox`.
|
|
46
|
+
*
|
|
47
|
+
* The effective context defaults to `"sandbox"` when `undefined` (some schemas
|
|
48
|
+
* default the value elsewhere), so an omitted instance is allowed here. That
|
|
49
|
+
* permissive default is safe only because it is no longer load-bearing: the
|
|
50
|
+
* resolved-hostname boundary has already approved the target before any handler
|
|
51
|
+
* runs, and it never consults this argument. Returning `null` therefore means
|
|
52
|
+
* "this input is not objectionable", never "this target is a sandbox".
|
|
21
53
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* elsewhere), so an omitted instance is allowed. Any other value returns the
|
|
25
|
-
* unified validation failure naming the rejected instance; a sandbox returns
|
|
26
|
-
* `null` so the caller proceeds.
|
|
54
|
+
* Any other value returns the unified validation failure naming the rejected
|
|
55
|
+
* context.
|
|
27
56
|
*
|
|
28
57
|
* `details` carries non-sensitive guard metadata only — the rejected value is
|
|
29
58
|
* caller-supplied.
|
|
@@ -28,8 +28,12 @@ import { WRITE_ANNOTATIONS, encodedSegment, preTransportErrorEnvelope, unexpecte
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
// Input schemas
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// OCAPI instance CONTEXT, not a target selector: the instance actually
|
|
32
|
+
// contacted is decided by the resolved-hostname boundary (BAPI-817), which this
|
|
33
|
+
// value cannot influence. "omit for sandbox" is gone because omitting it never
|
|
34
|
+
// selected anything.
|
|
35
|
+
const INSTANCE_DESCRIBE = "OCAPI instance context. Omit or pass \"sandbox\"; any other value is " +
|
|
36
|
+
"rejected before OCAPI is called.";
|
|
33
37
|
const createCustomObjectAttributeDefinitionInput = z.object({
|
|
34
38
|
object_type: z
|
|
35
39
|
.string()
|
|
@@ -28,8 +28,13 @@ import { WRITE_ANNOTATIONS, encodedSegment, preTransportErrorEnvelope, unexpecte
|
|
|
28
28
|
// Mirror reads-site-preference.ts so callers see the same instance enum, while
|
|
29
29
|
// the write remains sandbox-only (enforced by the guard + the literal path).
|
|
30
30
|
const INSTANCE_ENUM = z.enum(["staging", "development", "sandbox", "production"]);
|
|
31
|
+
// OCAPI instance CONTEXT only. The trailing "Defaults to 'sandbox'" is gone:
|
|
32
|
+
// it read as though the default selected a safe target, when the target is
|
|
33
|
+
// decided by the resolved-hostname boundary (BAPI-817) and this value cannot
|
|
34
|
+
// influence it. The forced `/sandbox` path segment below remains as an extra
|
|
35
|
+
// safeguard on the OCAPI route.
|
|
31
36
|
const INSTANCE_DESCRIBE = "OCAPI instance context. v1 supports the 'sandbox' context only; any other " +
|
|
32
|
-
"value is rejected before OCAPI is called.
|
|
37
|
+
"value is rejected before OCAPI is called.";
|
|
33
38
|
const sitePreferenceValuesSetInput = z.object({
|
|
34
39
|
group: z.string().describe("Custom site preference group id, e.g. 'LLMIntegration'."),
|
|
35
40
|
instance: INSTANCE_ENUM.optional().default("sandbox").describe(INSTANCE_DESCRIBE),
|
|
@@ -50,11 +50,20 @@ function preferenceObjectTypeForScope(scope) {
|
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
51
|
// Input schemas
|
|
52
52
|
// ---------------------------------------------------------------------------
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Optional OCAPI instance CONTEXT — not a target selector.
|
|
55
|
+
*
|
|
56
|
+
* The old description ("Sandbox-only: omit (defaults to sandbox) or pass
|
|
57
|
+
* \"sandbox\"") told the model that omitting this field selected a sandbox. It
|
|
58
|
+
* never did: the host came from `dw.json`/`SFCC_HOSTNAME`, and omitting the
|
|
59
|
+
* field only made `write-guard.ts` default to `"sandbox"` and pass. Which
|
|
60
|
+
* instance is actually contacted is decided by the resolved-hostname boundary in
|
|
61
|
+
* `tool-wrapper.ts` (BAPI-817), which this value cannot influence.
|
|
62
|
+
*/
|
|
54
63
|
const instanceSchema = z
|
|
55
64
|
.string()
|
|
56
65
|
.optional()
|
|
57
|
-
.describe("
|
|
66
|
+
.describe("OCAPI instance context. Omit or pass \"sandbox\".");
|
|
58
67
|
const createAttributeDefinitionInput = z.object({
|
|
59
68
|
object_type: z.string().describe("System object type, e.g. \"Product\"."),
|
|
60
69
|
attribute_id: z.string().describe("Attribute id (URL id); the body id must match it."),
|
package/build/sfcc/writes.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* populated by BAPI-584).
|
|
4
4
|
*
|
|
5
5
|
* This is the registration seam that write-surface tickets register through.
|
|
6
|
-
* `register.ts` wires
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
6
|
+
* `register.ts` wires it behind its OWN `includeWriteTools` gate, driven by the
|
|
7
|
+
* `sfcc-write` profile group (BAPI-817). The nine tools here are destructive, so
|
|
8
|
+
* they are no longer default-on with the reads: enabling `sfcc` registers the
|
|
9
|
+
* read profile and nothing from this module.
|
|
9
10
|
*
|
|
10
11
|
* Each write handler follows the foundation recipe:
|
|
11
12
|
* 1. Parse inputs.
|
|
@@ -25,7 +26,8 @@ import { registerSitePreferenceWriteTools, SITE_PREFERENCE_WRITE_TOOL_NAMES, } f
|
|
|
25
26
|
* Write tool names registered by this seam (centralized inventory). Extended by
|
|
26
27
|
* each write-surface ticket — BAPI-583 adds the six system-object write tools,
|
|
27
28
|
* BAPI-584 adds the custom-object attribute-definition and site-preference value
|
|
28
|
-
* write tools.
|
|
29
|
+
* write tools. These nine have SHIPPED; `registration-inventory.ts` reuses this
|
|
30
|
+
* array as the `sfcc-write` profile inventory rather than duplicating it.
|
|
29
31
|
*/
|
|
30
32
|
export const SFCC_WRITE_TOOL_NAMES = [
|
|
31
33
|
...SYSTEM_OBJECT_WRITE_TOOL_NAMES,
|
|
@@ -47,14 +49,17 @@ export const SFCC_WRITE_TOOL_NAMES = [
|
|
|
47
49
|
* 4. Call `ocapiPut` or `ocapiPatch` (client.ts) for transport.
|
|
48
50
|
* 5. Route the result through `formatOcapiWriteToolResult` (write-result.ts).
|
|
49
51
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
52
|
+
* Every handler registered here crosses three checks before it can mutate
|
|
53
|
+
* anything: the resolved-hostname sandbox boundary (registration-level,
|
|
54
|
+
* `tool-wrapper.ts`), the call-time `withSfccGate` version check, and the
|
|
55
|
+
* secondary instance-context guard in `write-guard.ts`. Only the first of those
|
|
56
|
+
* decides which instance is contacted.
|
|
53
57
|
*
|
|
54
58
|
* BAPI-583 registers the system-object write surface here; BAPI-584 adds the
|
|
55
59
|
* custom-object attribute-definition and site-preference value write surfaces
|
|
56
60
|
* alongside it. Later write-surface tickets add their modules the same way,
|
|
57
|
-
* without touching `register.ts`/`index.ts
|
|
61
|
+
* without touching `register.ts`/`index.ts` — a new tool added here is
|
|
62
|
+
* automatically inside the `sfcc-write` group and inside the target boundary.
|
|
58
63
|
*/
|
|
59
64
|
export function registerSfccWriteTools(registerTool, deps) {
|
|
60
65
|
// System-object attribute/group definition writes (BAPI-583).
|