@bridge_gpt/mcp-server 0.2.37 → 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 +193 -16
- 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/ocapi-shape.js +23 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +6 -2
- package/build/sfcc/reads-custom-object-def.js +33 -21
- package/build/sfcc/reads-site-preference.js +20 -7
- package/build/sfcc/reads-system-object.js +11 -5
- 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/write-result.js +16 -7
- package/build/sfcc/writes-custom-object-def.js +12 -4
- 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
package/build/sfcc/register.js
CHANGED
|
@@ -5,10 +5,32 @@
|
|
|
5
5
|
* SFCC tools with a single call adjacent to `registerConductorTools`. All
|
|
6
6
|
* SFCC business logic lives inside the sfcc/ module; index.ts stays a
|
|
7
7
|
* thin registration/composition layer.
|
|
8
|
+
*
|
|
9
|
+
* ---------------------------------------------------------------------------
|
|
10
|
+
* Two structural properties this module owns (BAPI-817)
|
|
11
|
+
* ---------------------------------------------------------------------------
|
|
12
|
+
*
|
|
13
|
+
* **1. The target boundary is unavoidable.** The raw host registrar is used in
|
|
14
|
+
* exactly one place — `registerSfccTool`, the adapter below — and every other
|
|
15
|
+
* registration in this module and in every child registrar goes through that
|
|
16
|
+
* adapter. So the resolved-hostname sandbox check runs for all twenty tools:
|
|
17
|
+
* the two always-on diagnostics, the eight reads (including the five with no
|
|
18
|
+
* `instance` parameter at all), the nine writes, and `sfcc_log_query`. Asking
|
|
19
|
+
* each handler to remember a check is what produced the unguarded reads in the
|
|
20
|
+
* first place; wrapping the registrar removes the opportunity to forget.
|
|
21
|
+
*
|
|
22
|
+
* **2. Reads and writes are separately gated.** Before BAPI-817 one boolean
|
|
23
|
+
* (`includeReadTools`, from `ACTIVE_GROUPS.has("sfcc")`) registered eighteen
|
|
24
|
+
* tools, nine of them destructive — while the README described the group as
|
|
25
|
+
* "the 7 heavy SFCC read tools" and stated "All SFCC tools are read-only".
|
|
26
|
+
* Enabling introspection silently enabled mutation. This module now takes two
|
|
27
|
+
* independent flags so the `sfcc` profile is genuinely read-oriented and the
|
|
28
|
+
* nine writes require the deliberate `sfcc-write` opt-in (the ticket's
|
|
29
|
+
* Decision 2(c)).
|
|
8
30
|
*/
|
|
9
31
|
import { z } from "zod";
|
|
10
32
|
import { buildSfccSetupStatusHandler } from "./setup-status.js";
|
|
11
|
-
import { withSfccGate } from "./tool-wrapper.js";
|
|
33
|
+
import { withSfccGate, withSfccTargetBoundary } from "./tool-wrapper.js";
|
|
12
34
|
import { checkPermissionsTool } from "./permissions.js";
|
|
13
35
|
import { registerSystemObjectReadTools } from "./reads-system-object.js";
|
|
14
36
|
import { registerSfccCustomObjectDefReadTools } from "./reads-custom-object-def.js";
|
|
@@ -22,7 +44,9 @@ import { registerSfccLogQueryTool } from "./log-query.js";
|
|
|
22
44
|
* Register all SFCC tools through the host's registerTool wrapper.
|
|
23
45
|
*
|
|
24
46
|
* Call this once in index.ts, immediately after `registerConductorTools(registerTool)`.
|
|
25
|
-
*
|
|
47
|
+
* Read and write surfaces are selected independently by `deps.includeReadTools`
|
|
48
|
+
* and `deps.includeWriteTools`; the two always-on diagnostics register
|
|
49
|
+
* regardless. Every path goes through `registerSfccTool`.
|
|
26
50
|
*/
|
|
27
51
|
export function registerSfccTools(registerTool, deps) {
|
|
28
52
|
const gateDeps = {
|
|
@@ -30,8 +54,17 @@ export function registerSfccTools(registerTool, deps) {
|
|
|
30
54
|
getGetHeaders: deps.getGetHeaders,
|
|
31
55
|
repoName: deps.repoName,
|
|
32
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* The one and only bridge between the host registrar and the SFCC surface.
|
|
59
|
+
*
|
|
60
|
+
* Every SFCC tool is registered through this, so every SFCC tool crosses the
|
|
61
|
+
* resolved-hostname boundary before its own handler runs. `registerTool` (the
|
|
62
|
+
* raw host registrar) must not be referenced anywhere else in this module or
|
|
63
|
+
* passed to a child registrar — `registration-boundary.test.ts` pins that.
|
|
64
|
+
*/
|
|
65
|
+
const registerSfccTool = (name, config, handler) => registerTool(name, config, withSfccTargetBoundary(handler));
|
|
33
66
|
// sfcc_setup_status — aggregate prerequisite reporter.
|
|
34
|
-
|
|
67
|
+
registerSfccTool("sfcc_setup_status", {
|
|
35
68
|
description: "Report on every SFCC prerequisite: Bridge API key, repo name, version config, " +
|
|
36
69
|
"dw.json presence/uniqueness, and AM token acquisition. Always-registered; " +
|
|
37
70
|
"returns status without requiring full SFCC configuration to be complete.",
|
|
@@ -40,53 +73,58 @@ export function registerSfccTools(registerTool, deps) {
|
|
|
40
73
|
}, buildSfccSetupStatusHandler(deps.buildGetUrl, deps.getGetHeaders, deps.repoName, deps.getResolvedApiKey));
|
|
41
74
|
// check_permissions — OCAPI access probe + settings JSON printer.
|
|
42
75
|
const gatedCheckPermissions = withSfccGate(gateDeps, async (_args, credentials) => checkPermissionsTool(credentials));
|
|
43
|
-
|
|
76
|
+
registerSfccTool("check_permissions", {
|
|
44
77
|
description: "Probe SFCC OCAPI access via GET /system_object_definitions. " +
|
|
45
78
|
"On 200: reports OK and the detected OCAPI version. " +
|
|
46
79
|
"On 401/403: prints the exact OCAPI Settings JSON to paste in Business Manager " +
|
|
47
|
-
"(
|
|
80
|
+
"(read/search and mutation grants).",
|
|
48
81
|
inputSchema: z.object({
|
|
49
82
|
instance: z
|
|
50
83
|
.string()
|
|
84
|
+
.min(1)
|
|
51
85
|
.optional()
|
|
52
|
-
.describe("
|
|
86
|
+
.describe("Sandbox hostname to use instead of dw.json. Still sandbox-validated."),
|
|
53
87
|
}),
|
|
54
88
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
55
89
|
}, gatedCheckPermissions);
|
|
56
|
-
//
|
|
57
|
-
//
|
|
90
|
+
// The read profile — eight OCAPI reads plus sfcc_log_query — is gated behind
|
|
91
|
+
// ACTIVE_GROUPS.has("sfcc") (BAPI-453). sfcc_setup_status and
|
|
92
|
+
// check_permissions above remain always-on (D-4 decision).
|
|
58
93
|
if (deps.includeReadTools) {
|
|
59
94
|
// system_object_list, system_object_get, system_object_attribute_search (BAPI-401 T1–T3)
|
|
60
|
-
registerSystemObjectReadTools(
|
|
95
|
+
registerSystemObjectReadTools(registerSfccTool, {
|
|
61
96
|
gateDeps,
|
|
62
97
|
getDocsDir: deps.getDocsDir,
|
|
63
98
|
});
|
|
64
99
|
// custom_object_definition_attributes_get, custom_object_definition_attribute_search (BAPI-402 T4–T5)
|
|
65
|
-
registerSfccCustomObjectDefReadTools(
|
|
66
|
-
gateDeps,
|
|
67
|
-
getDocsDir: deps.getDocsDir,
|
|
68
|
-
});
|
|
69
|
-
// site_preference_get, site_preference_search (BAPI-403 T6 — optional)
|
|
70
|
-
registerSitePreferenceTools(registerTool, {
|
|
100
|
+
registerSfccCustomObjectDefReadTools(registerSfccTool, {
|
|
71
101
|
gateDeps,
|
|
72
102
|
getDocsDir: deps.getDocsDir,
|
|
73
103
|
});
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
// branch so writes become default-on with reads whenever the sfcc profile
|
|
77
|
-
// group is active.
|
|
78
|
-
registerSfccWriteTools(registerTool, {
|
|
104
|
+
// site_preference_group_list, site_preference_get, site_preference_search (BAPI-403 T6)
|
|
105
|
+
registerSitePreferenceTools(registerSfccTool, {
|
|
79
106
|
gateDeps,
|
|
80
107
|
getDocsDir: deps.getDocsDir,
|
|
81
108
|
});
|
|
82
109
|
// sfcc_log_query (BAPI-556 T7) — gated, on-demand redacted/filtered log query.
|
|
83
|
-
// Uses its OWN call-time gate (log capability = WebDAV Basic
|
|
84
|
-
// OCAPI version/dw.json/AM-token gate above.
|
|
85
|
-
|
|
110
|
+
// Uses its OWN call-time capability gate (log capability = WebDAV Basic
|
|
111
|
+
// auth), NOT the OCAPI version/dw.json/AM-token gate above. It is read-only,
|
|
112
|
+
// so it belongs to the read profile.
|
|
113
|
+
registerSfccLogQueryTool(registerSfccTool, {
|
|
86
114
|
buildGetUrl: deps.buildGetUrl,
|
|
87
115
|
getGetHeaders: deps.getGetHeaders,
|
|
88
116
|
getPostHeaders: deps.getPostHeaders,
|
|
89
117
|
repoName: deps.repoName,
|
|
90
118
|
});
|
|
91
119
|
}
|
|
120
|
+
// The nine destructive write tools are gated INDEPENDENTLY, behind
|
|
121
|
+
// ACTIVE_GROUPS.has("sfcc-write") (BAPI-817). This branch is deliberately not
|
|
122
|
+
// nested inside the read branch: `sfcc-write` alone is a valid selection, and
|
|
123
|
+
// `sfcc` alone must never register a tool carrying WRITE_ANNOTATIONS.
|
|
124
|
+
if (deps.includeWriteTools) {
|
|
125
|
+
registerSfccWriteTools(registerSfccTool, {
|
|
126
|
+
gateDeps,
|
|
127
|
+
getDocsDir: deps.getDocsDir,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
92
130
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical inventory of every SFCC MCP tool name, by registration mode
|
|
3
|
+
* (BAPI-816).
|
|
4
|
+
*
|
|
5
|
+
* `register.ts` (`registerSfccTools`) decides WHICH tools register; this
|
|
6
|
+
* module is the single readable description of what that decision produces.
|
|
7
|
+
* Registrar tests compare captured registrations against these composed
|
|
8
|
+
* arrays instead of independently maintained numeric pins, which is exactly
|
|
9
|
+
* what let `register-site-preference.integration.test.ts` pin a stale `10`
|
|
10
|
+
* while the real registrar had already grown to 20 — a full write surface's
|
|
11
|
+
* worth of drift that no test caught (BAPI-816).
|
|
12
|
+
*
|
|
13
|
+
* `includeReadTools` is the legacy `SfccHostDeps` flag name (`register.ts`).
|
|
14
|
+
* Since BAPI-817 it gates the READ profile only — the eight OCAPI reads plus
|
|
15
|
+
* the log-query tool — while `includeWriteTools` independently gates the nine
|
|
16
|
+
* writes. The name predates both surfaces and was kept for call-site stability.
|
|
17
|
+
*/
|
|
18
|
+
import { SFCC_WRITE_TOOL_NAMES } from "./writes.js";
|
|
19
|
+
/**
|
|
20
|
+
* Registered unconditionally, regardless of `includeReadTools` (D-4 decision
|
|
21
|
+
* in register.ts): the aggregate prerequisite reporter and the OCAPI access
|
|
22
|
+
* probe.
|
|
23
|
+
*/
|
|
24
|
+
export const SFCC_ALWAYS_REGISTERED_TOOL_NAMES = [
|
|
25
|
+
"sfcc_setup_status",
|
|
26
|
+
"check_permissions",
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* The eight read tools registered by the three read-module registrars
|
|
30
|
+
* (`registerSystemObjectReadTools`, `registerSfccCustomObjectDefReadTools`,
|
|
31
|
+
* `registerSitePreferenceTools`) when `includeReadTools` is true.
|
|
32
|
+
*/
|
|
33
|
+
export const SFCC_READ_TOOL_NAMES = [
|
|
34
|
+
"system_object_list",
|
|
35
|
+
"system_object_get",
|
|
36
|
+
"system_object_attribute_search",
|
|
37
|
+
"custom_object_definition_attributes_get",
|
|
38
|
+
"custom_object_definition_attribute_search",
|
|
39
|
+
"site_preference_group_list",
|
|
40
|
+
"site_preference_get",
|
|
41
|
+
"site_preference_search",
|
|
42
|
+
];
|
|
43
|
+
/** The on-demand redacted/filtered log-query tool (BAPI-556 T7). */
|
|
44
|
+
export const SFCC_LOG_QUERY_TOOL_NAME = "sfcc_log_query";
|
|
45
|
+
/**
|
|
46
|
+
* The `sfcc` READ profile: the eight OCAPI reads plus the log-query tool
|
|
47
|
+
* (BAPI-817).
|
|
48
|
+
*
|
|
49
|
+
* This is the exact set `includeReadTools` registers. Every member is read-only
|
|
50
|
+
* and carries read-only annotations; by construction no tool annotated
|
|
51
|
+
* `WRITE_ANNOTATIONS` can appear here, which is what makes "enabling `sfcc`
|
|
52
|
+
* cannot enable a mutation" checkable rather than merely intended.
|
|
53
|
+
*/
|
|
54
|
+
export const SFCC_READ_PROFILE_TOOL_NAMES = [
|
|
55
|
+
...SFCC_READ_TOOL_NAMES,
|
|
56
|
+
SFCC_LOG_QUERY_TOOL_NAME,
|
|
57
|
+
];
|
|
58
|
+
/**
|
|
59
|
+
* The `sfcc-write` WRITE profile: the nine destructive tools (BAPI-817).
|
|
60
|
+
*
|
|
61
|
+
* Reused from `writes.ts#SFCC_WRITE_TOOL_NAMES` rather than duplicated, so the
|
|
62
|
+
* profile inventory and the registrar's own list cannot drift apart.
|
|
63
|
+
*/
|
|
64
|
+
export const SFCC_WRITE_PROFILE_TOOL_NAMES = [
|
|
65
|
+
...SFCC_WRITE_TOOL_NAMES,
|
|
66
|
+
];
|
|
67
|
+
/**
|
|
68
|
+
* Every profile-gated tool, across both groups.
|
|
69
|
+
*
|
|
70
|
+
* Retained as the aggregate for consumers that care about "gated vs always-on"
|
|
71
|
+
* rather than which group gates it — but composed from the two profile
|
|
72
|
+
* inventories above, never hand-maintained. Order matches registration order in
|
|
73
|
+
* `register.ts`: the read branch runs first, then the write branch.
|
|
74
|
+
*/
|
|
75
|
+
export const SFCC_PROFILE_GATED_TOOL_NAMES = [
|
|
76
|
+
...SFCC_READ_PROFILE_TOOL_NAMES,
|
|
77
|
+
...SFCC_WRITE_PROFILE_TOOL_NAMES,
|
|
78
|
+
];
|
|
79
|
+
/**
|
|
80
|
+
* The complete SFCC registration surface: always-on plus both gated profiles.
|
|
81
|
+
*
|
|
82
|
+
* This is the twenty-tool surface a `BRIDGE_MCP_PROFILE=sfcc,sfcc-write` (or
|
|
83
|
+
* `full`) session exposes, and the iteration source for the boundary-coverage
|
|
84
|
+
* test — so a newly added tool cannot silently evade that coverage.
|
|
85
|
+
*/
|
|
86
|
+
export const SFCC_ALL_REGISTERED_TOOL_NAMES = [
|
|
87
|
+
...SFCC_ALWAYS_REGISTERED_TOOL_NAMES,
|
|
88
|
+
...SFCC_PROFILE_GATED_TOOL_NAMES,
|
|
89
|
+
];
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* prevent the others from running.
|
|
7
7
|
*/
|
|
8
8
|
import { SFCC_VERSIONS, getSfccVersionConfig } from "./config.js";
|
|
9
|
-
import { resolveSfccCredentials } from "./credentials.js";
|
|
10
9
|
import { getAmToken } from "./client.js";
|
|
11
10
|
// ---------------------------------------------------------------------------
|
|
12
11
|
// Implementation
|
|
@@ -57,39 +56,20 @@ export async function sfccSetupStatusTool(deps) {
|
|
|
57
56
|
versionStatus = "— Skipped (Bridge API not configured)";
|
|
58
57
|
}
|
|
59
58
|
lines.push(`3. SFCC Version: ${versionStatus}`);
|
|
60
|
-
// 4. dw.json / credential resolution
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
source: result.credentials.source,
|
|
70
|
-
};
|
|
71
|
-
credStatus = `✓ Found (${resolvedCredentials.source})`;
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
credStatus = `✗ ${result.error}`;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
catch (err) {
|
|
78
|
-
credStatus = `✗ Resolution error: ${err instanceof Error ? err.message : String(err)}`;
|
|
79
|
-
}
|
|
80
|
-
lines.push(`4. dw.json / Credentials: ${credStatus}`);
|
|
81
|
-
// 5. AM token acquisition (only if credentials were resolved)
|
|
59
|
+
// 4. dw.json / credential resolution — read from the boundary-approved target
|
|
60
|
+
// rather than resolved again here (BAPI-817).
|
|
61
|
+
const target = deps.target;
|
|
62
|
+
// Report source and instance ID only — never the secret values.
|
|
63
|
+
const instanceLabel = target.approved
|
|
64
|
+
? target.hostname.split(".")[0] ?? target.hostname
|
|
65
|
+
: null;
|
|
66
|
+
lines.push(`4. dw.json / Credentials: ${target.approved ? `✓ Found (${target.credentials.source})` : `✗ ${target.error}`}`);
|
|
67
|
+
// 5. AM token acquisition (only when the boundary approved a target).
|
|
82
68
|
let tokenStatus = "— Skipped (credentials not available)";
|
|
83
|
-
if (
|
|
69
|
+
if (target.approved) {
|
|
84
70
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
await getAmToken(credResult.credentials);
|
|
88
|
-
tokenStatus = `✓ Token acquired for instance ${resolvedCredentials.hostname}`;
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
tokenStatus = "✗ Credentials not resolved";
|
|
92
|
-
}
|
|
71
|
+
await getAmToken(target.credentials);
|
|
72
|
+
tokenStatus = `✓ Token acquired for instance ${instanceLabel}`;
|
|
93
73
|
}
|
|
94
74
|
catch (err) {
|
|
95
75
|
// Sanitize: strip any token/secret-looking content from error message.
|
|
@@ -135,10 +115,14 @@ export async function sfccSetupStatusTool(deps) {
|
|
|
135
115
|
}
|
|
136
116
|
/**
|
|
137
117
|
* Build the sfcc_setup_status handler bound to the host's runtime deps.
|
|
118
|
+
*
|
|
119
|
+
* The returned handler takes the `SfccTargetContext` the registration-level
|
|
120
|
+
* sandbox boundary supplies (BAPI-817) and forwards it, so this tool reports on
|
|
121
|
+
* the same target the boundary approved and never resolves one of its own.
|
|
138
122
|
*/
|
|
139
123
|
export function buildSfccSetupStatusHandler(buildGetUrl, getGetHeaders, repoName, getApiKey) {
|
|
140
|
-
return async (_args) => {
|
|
124
|
+
return async (_args, target) => {
|
|
141
125
|
const apiKey = await getApiKey();
|
|
142
|
-
return sfccSetupStatusTool({ buildGetUrl, getGetHeaders, repoName, apiKey });
|
|
126
|
+
return sfccSetupStatusTool({ buildGetUrl, getGetHeaders, repoName, apiKey, target });
|
|
143
127
|
};
|
|
144
128
|
}
|
|
@@ -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
|
}
|