@bridge_gpt/mcp-server 0.2.36 → 0.2.38

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 (92) hide show
  1. package/README.md +48 -8
  2. package/build/base-url.js +79 -0
  3. package/build/bridge-api-urls.js +9 -0
  4. package/build/chain-orchestrator.js +93 -15
  5. package/build/claude-user-config-doctor.js +317 -0
  6. package/build/commands.generated.js +2 -1
  7. package/build/conductor/bridge-api-client.js +178 -4
  8. package/build/conductor-bin.js +1 -1
  9. package/build/conductor-bundle-artifacts.js +7 -6
  10. package/build/credential-store.js +205 -4
  11. package/build/direct-ticket-tools.js +70 -0
  12. package/build/doctor.js +239 -80
  13. package/build/executor/cli.js +51 -1
  14. package/build/executor/credentials.js +1 -7
  15. package/build/executor/deps.js +18 -1
  16. package/build/executor/env.js +51 -25
  17. package/build/executor/heartbeat.js +138 -17
  18. package/build/executor/http-client.js +49 -8
  19. package/build/executor/job-errors.js +4 -0
  20. package/build/executor/job-runner.js +422 -22
  21. package/build/executor/observation.js +130 -0
  22. package/build/executor/permissions.js +104 -8
  23. package/build/executor/preflight.js +32 -0
  24. package/build/executor/runner.js +8 -0
  25. package/build/executor/test-clock.js +67 -3
  26. package/build/executor/types.js +4 -1
  27. package/build/executor/worker-command.js +11 -3
  28. package/build/executor/worker-config-isolation.js +287 -0
  29. package/build/executor/worker-finalization.js +68 -14
  30. package/build/executor/worktree.js +46 -4
  31. package/build/index.js +614 -244
  32. package/build/init.js +363 -73
  33. package/build/install-bridge.js +568 -80
  34. package/build/launcher-config-inspection.js +351 -0
  35. package/build/mcp-invoke.js +49 -6
  36. package/build/mcp-provisioning.js +30 -7
  37. package/build/mcp-registration-doctor.js +14 -5
  38. package/build/notifications.js +553 -0
  39. package/build/pipeline-orchestrator.js +146 -4
  40. package/build/pipeline-utils.js +3 -0
  41. package/build/pipelines.generated.js +22 -9
  42. package/build/plan-execution-ledger.js +550 -0
  43. package/build/plan-phase-routing.js +272 -0
  44. package/build/plane/alembic-head.js +110 -0
  45. package/build/plane/build-freshness.js +167 -0
  46. package/build/plane/cli.js +480 -0
  47. package/build/plane/defaults.js +266 -0
  48. package/build/plane/manifest.js +377 -0
  49. package/build/plane/member-logs.js +147 -0
  50. package/build/plane/member-roster.js +147 -0
  51. package/build/plane/preflight.js +289 -0
  52. package/build/plane/shutdown.js +195 -0
  53. package/build/plane/status.js +125 -0
  54. package/build/plane/supervisor.js +569 -0
  55. package/build/plane/test-fakes.js +156 -0
  56. package/build/plane/types.js +75 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/run-unit-tests-launcher.js +2 -0
  59. package/build/setup-epic.js +662 -27
  60. package/build/sfcc/log-gate.js +38 -11
  61. package/build/sfcc/log-query.js +55 -15
  62. package/build/sfcc/ocapi-shape.js +70 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-body.js +92 -0
  66. package/build/sfcc/read-projection.js +185 -0
  67. package/build/sfcc/read-result.js +158 -0
  68. package/build/sfcc/reads-custom-object-def.js +57 -34
  69. package/build/sfcc/reads-site-preference.js +86 -33
  70. package/build/sfcc/reads-system-object.js +50 -38
  71. package/build/sfcc/sfcc-result.js +106 -0
  72. package/build/sfcc/tool-wrapper.js +56 -13
  73. package/build/sfcc/write-grants.js +45 -22
  74. package/build/sfcc/write-guard.js +21 -13
  75. package/build/sfcc/write-result.js +71 -15
  76. package/build/sfcc/write-tool-common.js +126 -32
  77. package/build/sfcc/writes-custom-object-def.js +6 -2
  78. package/build/sfcc/writes-system-object.js +11 -50
  79. package/build/start-tickets-prereqs.js +129 -0
  80. package/build/start-tickets.js +17 -13
  81. package/build/ticket-backend-metadata.js +59 -0
  82. package/build/ticket-key-utils.js +92 -0
  83. package/build/tool-error-envelope.js +71 -0
  84. package/build/tool-surface-gating.js +72 -0
  85. package/build/update-status.js +102 -0
  86. package/build/upgrade-advice.js +47 -0
  87. package/build/upgrade-cli.js +417 -101
  88. package/build/version.generated.js +1 -1
  89. package/build/worktree-core.js +73 -0
  90. package/docs/CONDUCTOR.md +23 -8
  91. package/package.json +3 -3
  92. package/pipelines/implement-ticket.json +15 -5
@@ -11,20 +11,36 @@
11
11
  * (BM username + 40-char WebDAV access key) that lives entirely server-side; the
12
12
  * spike proved an AM bearer token 401s on `/Logs`. Readiness is resolved by the
13
13
  * backend `GET /jira/sfcc/logs/capability` probe, which never returns a secret.
14
+ *
15
+ * BAPI-766 changed only the *representation* of this gate's failures: they now
16
+ * use the universal SFCC envelope from `sfcc-result.ts`, so one detection rule
17
+ * (`result.isError === true`) and one parse path cover the log surface too. The
18
+ * gate itself remains operationally separate — the WebDAV Basic-auth boundary is
19
+ * still distinct from the OCAPI AM-OAuth boundary, and the two gates are not
20
+ * merged. A capability-probe failure is reported with source `"webdav"`, never
21
+ * `"ocapi"`, because that is where it actually originates.
14
22
  */
23
+ import { formatSfccFailure } from "./sfcc-result.js";
15
24
  // ---------------------------------------------------------------------------
16
25
  // Envelope helpers
17
26
  // ---------------------------------------------------------------------------
18
- function jsonResult(payload) {
19
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
20
- }
27
+ /**
28
+ * Build the unified log-gate not-configured failure.
29
+ *
30
+ * The 503 is synthetic — Bridge's own status, not something WebDAV returned — so
31
+ * no upstream HTTP status is supplied. `source` is `"gate"`, matching the OCAPI
32
+ * gate: the rejection is the gate's decision, not an upstream response.
33
+ */
21
34
  function notConfigured(failureClass, message, limits) {
22
- return jsonResult({
23
- error: "NOT_CONFIGURED",
35
+ return formatSfccFailure({
36
+ code: "NOT_CONFIGURED",
24
37
  status: 503,
25
- failure_class: failureClass,
26
38
  message,
27
- ...(limits !== undefined ? { limits } : {}),
39
+ source: "gate",
40
+ details: {
41
+ failure_class: failureClass,
42
+ ...(limits !== undefined ? { limits } : {}),
43
+ },
28
44
  });
29
45
  }
30
46
  // ---------------------------------------------------------------------------
@@ -54,22 +70,33 @@ export function withSfccLogGate(deps, handler) {
54
70
  const resp = await fetch(url, { headers: await deps.getGetHeaders() });
55
71
  if (!resp.ok) {
56
72
  // Distinguish a Bridge auth/connectivity failure from "not configured".
57
- return jsonResult({
58
- error: resp.status === 401 || resp.status === 403 ? "UNAUTHORIZED" : "SERVICE_UNAVAILABLE",
73
+ // The reported status IS the upstream status, so the shared formatter
74
+ // rightly omits a duplicate `upstream_http_status`.
75
+ return formatSfccFailure({
76
+ code: resp.status === 401 || resp.status === 403
77
+ ? "UNAUTHORIZED"
78
+ : "SERVICE_UNAVAILABLE",
59
79
  status: resp.status,
60
80
  message: "Could not read the SFCC log capability from Bridge API " +
61
81
  "(/jira/sfcc/logs/capability). Ensure your Bridge API key is set and the " +
62
82
  "repo is authorized. Run sfcc_setup_status for a full diagnostic.",
83
+ source: "webdav",
84
+ details: {},
85
+ upstreamHttpStatus: resp.status,
63
86
  });
64
87
  }
65
88
  body = (await resp.json());
66
89
  }
67
90
  catch {
68
- return jsonResult({
69
- error: "BAD_GATEWAY",
91
+ // A synthetic 502: no upstream responded at all, so no upstream status
92
+ // may be reported.
93
+ return formatSfccFailure({
94
+ code: "BAD_GATEWAY",
70
95
  status: 502,
71
96
  message: "Could not reach Bridge API to resolve the SFCC log capability. " +
72
97
  "Check that BAPI_BASE_URL points to a running Bridge API instance.",
98
+ source: "webdav",
99
+ details: {},
73
100
  });
74
101
  }
75
102
  if (body?.configured !== true) {
@@ -11,9 +11,17 @@
11
11
  * The backend response has already passed through the `RedactionPort`, so its JSON
12
12
  * is returned to the LLM unchanged; there is deliberately NO local retrieval
13
13
  * bypass in TypeScript.
14
+ *
15
+ * BAPI-766 moved this tool's failure representation onto the universal SFCC
16
+ * envelope (`sfcc-result.ts`), so `result.isError === true` and one parse path
17
+ * cover the log surface exactly as they cover reads and writes. Redaction is
18
+ * untouched: a non-OK backend response contributes only its Bridge-authored
19
+ * string `detail` to `error.details`, and never raw text or an unknown payload
20
+ * whose redaction cannot be established (see `safeBackendDetail`).
14
21
  */
15
22
  import { z } from "zod";
16
23
  import { withSfccLogGate } from "./log-gate.js";
24
+ import { formatSfccFailure } from "./sfcc-result.js";
17
25
  // ---------------------------------------------------------------------------
18
26
  // Bounds — mirror src/python/sfcc_monitor/log_query.py (kept in lock-step).
19
27
  // ---------------------------------------------------------------------------
@@ -24,11 +32,42 @@ const MAX_SELECTED_PREFIXES = 5;
24
32
  const MAX_MAX_ENTRIES = 2000;
25
33
  const SUPPORTED_ENVIRONMENTS = ["production", "staging", "development"];
26
34
  const PREFIX_RE = /^[a-z][a-z0-9]*$/;
27
- function jsonResult(payload) {
28
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
35
+ /** Bridge-authored message for a non-OK backend query response. */
36
+ function backendFailureMessage(status) {
37
+ return (`The Bridge API SFCC log-query endpoint returned HTTP ${status}. ` +
38
+ `No log content is included in this response.`);
39
+ }
40
+ /**
41
+ * Project a non-OK backend response body down to what is provably safe.
42
+ *
43
+ * Every failure the backend raises (`api/routes/sfcc_log_query.py`) is an
44
+ * `HTTPException` whose `detail` is a fixed Bridge-authored string — never log
45
+ * content. Anything else (a request-validation 422's structured list, an
46
+ * unparsed body, an unexpected payload) has no redaction guarantee, so it is
47
+ * dropped rather than forwarded. Redaction is a shipped safety property
48
+ * (BAPI-554); reformatting must not become a way around it.
49
+ */
50
+ function safeBackendDetail(text) {
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(text);
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
59
+ return {};
60
+ const detail = parsed.detail;
61
+ return typeof detail === "string" ? { detail } : {};
29
62
  }
30
63
  function validationError(message) {
31
- return jsonResult({ error: "VALIDATION_ERROR", status: 400, message });
64
+ return formatSfccFailure({
65
+ code: "VALIDATION_ERROR",
66
+ status: 400,
67
+ message,
68
+ source: "tool",
69
+ details: {},
70
+ });
32
71
  }
33
72
  // ---------------------------------------------------------------------------
34
73
  // Zod input schema
@@ -118,26 +157,27 @@ function buildHandler(deps) {
118
157
  });
119
158
  }
120
159
  catch {
121
- return jsonResult({
122
- error: "BAD_GATEWAY",
160
+ // Synthetic 502: nothing responded, so no upstream status may be reported.
161
+ return formatSfccFailure({
162
+ code: "BAD_GATEWAY",
123
163
  status: 502,
124
164
  message: "Could not reach Bridge API to run the SFCC log query. Check that " +
125
165
  "BAPI_BASE_URL points to a running Bridge API instance.",
166
+ source: "webdav",
167
+ details: {},
126
168
  });
127
169
  }
128
170
  const text = await resp.text();
129
171
  if (!resp.ok) {
130
- let detail = text;
131
- try {
132
- detail = JSON.parse(text);
133
- }
134
- catch {
135
- /* keep raw text detail */
136
- }
137
- return jsonResult({
138
- error: resp.status >= 500 ? "SERVICE_UNAVAILABLE" : "REQUEST_FAILED",
172
+ // The reported status IS the upstream status, so the shared formatter
173
+ // rightly omits a duplicate `upstream_http_status`.
174
+ return formatSfccFailure({
175
+ code: resp.status >= 500 ? "SERVICE_UNAVAILABLE" : "REQUEST_FAILED",
139
176
  status: resp.status,
140
- detail,
177
+ message: backendFailureMessage(resp.status),
178
+ source: "webdav",
179
+ details: safeBackendDetail(text),
180
+ upstreamHttpStatus: resp.status,
141
181
  });
142
182
  }
143
183
  // The backend response has already passed through RedactionPort — return it
@@ -5,13 +5,56 @@
5
5
  * envelope. This module provides a typed extractor for the `data` array so
6
6
  * sub-task read tools share one normalizer rather than each re-deriving the
7
7
  * shape.
8
+ *
9
+ * Collection metadata lives under a single `page` object rather than at the top
10
+ * level (BAPI-760). Two properties of that metadata are load-bearing:
11
+ *
12
+ * - `returned` is the actual length of the `items` array this call produced.
13
+ * It is never read from the upstream `count` field, because OCAPI's `count`
14
+ * is a request-echo (the page size asked for) and not a guarantee about how
15
+ * many records came back.
16
+ * - `total` exists ONLY when OCAPI itself supplied a numeric `total`. It is
17
+ * never inferred from `items.length`, and it is absent — not `null`, not
18
+ * `0` — when OCAPI omitted it. A consumer that sees no `total` knows the
19
+ * instance-wide count is genuinely unavailable rather than zero.
20
+ *
21
+ * Nesting the metadata under `page` also keeps it from colliding with fields
22
+ * that legitimately live inside the returned resources: an OCAPI attribute
23
+ * search document carries its own `count` and `hits`, and those stay untouched
24
+ * inside `items`.
25
+ *
26
+ * `page` is built strictly from properties of the collection envelope itself,
27
+ * never from a field promoted out of an individual item (BAPI-769). A bare
28
+ * object response is the one shape where this matters concretely: `items` is
29
+ * always that single object, so its own internal `total`/`count` describes hits
30
+ * *inside* the document, not a count of `items`, and is never attached to
31
+ * `page.total` — see the bare-object branch of `normalizeOcapiBody` below.
32
+ */
33
+ /**
34
+ * Single constructor for page metadata, shared by both normalizers.
35
+ *
36
+ * Centralizing it means `returned`, `total`, and `has_more` cannot be derived
37
+ * two different ways in the strict and permissive paths. `total` is attached
38
+ * conditionally so an unsupplied total is an absent property rather than an
39
+ * explicit `undefined` that would serialize as a present-but-empty field.
8
40
  */
41
+ function buildPageMeta(items, rawTotal, rawNext) {
42
+ const page = {
43
+ returned: items.length,
44
+ has_more: rawNext != null,
45
+ };
46
+ if (typeof rawTotal === "number") {
47
+ page.total = rawTotal;
48
+ }
49
+ return page;
50
+ }
9
51
  /**
10
52
  * Parse a raw OCAPI response body into a normalized envelope result.
11
53
  *
12
54
  * Returns `null` when `body` is not a valid OCAPI paging envelope (missing
13
55
  * `data` array or `count` field), allowing callers to surface a structured
14
- * error instead of crashing.
56
+ * error instead of crashing. Strict validation is unchanged; only the returned
57
+ * metadata shape moved under `page`.
15
58
  */
16
59
  export function normalizeOcapiPage(body) {
17
60
  if (body === null ||
@@ -23,9 +66,7 @@ export function normalizeOcapiPage(body) {
23
66
  const envelope = body;
24
67
  return {
25
68
  items: envelope.data,
26
- count: envelope.count,
27
- total: envelope.total,
28
- hasMore: envelope.next != null,
69
+ page: buildPageMeta(envelope.data, envelope.total, envelope.next),
29
70
  };
30
71
  }
31
72
  /**
@@ -40,11 +81,25 @@ export function normalizeOcapiPage(body) {
40
81
  *
41
82
  * Unlike `normalizeOcapiPage`, this function never returns null and is
42
83
  * forward-compatible with future OCAPI envelope shapes.
84
+ *
85
+ * Items are passed through verbatim in every branch. A bare object — including
86
+ * an attribute-search document with its own `count` and `hits` — becomes the
87
+ * single item unchanged; its `count` is never promoted to collection metadata,
88
+ * and a missing `hits` is never synthesized.
89
+ *
90
+ * `page` describes only the normalized `items` array; it is never built from a
91
+ * field promoted out of an item's own body (BAPI-769). The bare-object branch
92
+ * always produces exactly one item — the whole document — so a `total` count
93
+ * OCAPI reported about the document's own internal contents (e.g. a search
94
+ * document's zero-hit `total: 0`) is never attached to `page.total`, where it
95
+ * would misread as "0 of 1 returned item" for a wrapper whose `returned` is
96
+ * always 1. `page.total` is therefore absent on every bare-object response;
97
+ * `has_more` is unaffected — it still reflects the bare object's own `next`.
43
98
  */
44
99
  export function normalizeOcapiBody(body) {
45
100
  // Bare array
46
101
  if (Array.isArray(body)) {
47
- return { items: body, count: body.length, total: undefined, hasMore: false };
102
+ return { items: body, page: buildPageMeta(body, undefined, null) };
48
103
  }
49
104
  // Object forms
50
105
  if (body !== null && typeof body === "object") {
@@ -52,18 +107,19 @@ export function normalizeOcapiBody(body) {
52
107
  // Wrapped envelope: has a data array (with or without explicit count)
53
108
  if (Array.isArray(obj.data)) {
54
109
  const items = obj.data;
55
- return {
56
- items,
57
- count: typeof obj.count === "number" ? obj.count : items.length,
58
- total: typeof obj.total === "number" ? obj.total : undefined,
59
- hasMore: obj.next != null,
60
- };
110
+ return { items, page: buildPageMeta(items, obj.total, obj.next) };
61
111
  }
62
- // Bare object (single resource, no wrapping envelope)
63
- return { items: [body], count: 1, total: undefined, hasMore: false };
112
+ // Bare object (single resource, no wrapping envelope). `items` is always
113
+ // the single wrapper document, so `returned` is always 1 — a `total` (and,
114
+ // for a search document, `count`/`hits`) reported INSIDE that document is
115
+ // never promoted to `page.total`: it describes hits within the document,
116
+ // not a count of `items`. `page.total` is therefore always absent here
117
+ // (BAPI-769); `next` is still honored for `has_more`, which is unaffected.
118
+ const items = [body];
119
+ return { items, page: buildPageMeta(items, undefined, obj.next) };
64
120
  }
65
121
  // Non-object (null, string, number, etc.)
66
- return { items: [], count: 0, total: undefined, hasMore: false };
122
+ return { items: [], page: buildPageMeta([], undefined, null) };
67
123
  }
68
124
  /**
69
125
  * SPEC §4.5 `value_type` mapper.
@@ -4,23 +4,30 @@
4
4
  * Re-exports the truncation + docs-path utilities through a stable sfcc/
5
5
  * interface so sub-task read tools import from here instead of reaching into
6
6
  * the host module directly. This boundary makes a future extraction cheap.
7
+ *
8
+ * Oversized responses return a JSON descriptor rather than a sliced prefix
9
+ * (BAPI-760). Slicing a serialized JSON payload at a fixed character offset
10
+ * almost always cuts mid-token, so what reached the caller was neither the data
11
+ * nor valid JSON — and appending an English note made it unparseable by
12
+ * construction. Every branch here now emits JSON the caller can parse, and the
13
+ * collection metadata travels outside the omitted data so `returned`, `total`,
14
+ * and `has_more` survive truncation.
7
15
  */
8
16
  import path from "path";
9
17
  import { mkdir, writeFile } from "fs/promises";
10
18
  /** Maximum characters returned inline; content beyond this is saved locally. */
11
19
  export const SFCC_MAX_INLINE = 50_000;
12
- /** Truncation note appended to truncated payloads. */
13
- function truncationNote(savedPath) {
14
- return `\n\n[Response truncated — full payload saved to ${savedPath}]`;
15
- }
16
20
  /**
17
21
  * If `text` exceeds `SFCC_MAX_INLINE`, save it to `dir/filename` and return a
18
- * truncated version with a local-path note. Otherwise returns `text` unchanged.
22
+ * JSON truncation descriptor naming the saved file and carrying `page`.
23
+ * Otherwise returns `text` unchanged.
19
24
  *
20
- * Write failures are non-fatal: the full (un-truncated) text is returned with
21
- * a warning rather than throwing.
25
+ * Write failures are non-fatal: a JSON save-failure descriptor carrying the
26
+ * complete payload is returned rather than throwing. This is deliberately kept
27
+ * on the successful-read path — the OCAPI read itself succeeded, so it does not
28
+ * route through `formatSfccReadFailure` and does not set `isError`.
22
29
  */
23
- export async function truncateAndSaveIfNeeded(text, dir, filename, deps = {}) {
30
+ export async function truncateAndSaveIfNeeded(text, dir, filename, page, deps = {}) {
24
31
  if (text.length <= SFCC_MAX_INLINE) {
25
32
  return text;
26
33
  }
@@ -32,8 +39,31 @@ export async function truncateAndSaveIfNeeded(text, dir, filename, deps = {}) {
32
39
  await wf(filePath, text, "utf-8");
33
40
  }
34
41
  catch (err) {
35
- return (text +
36
- `\n\nWarning: response was NOT truncated because local save failed: ${err instanceof Error ? err.message : String(err)}`);
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ const descriptor = {
44
+ truncated: false,
45
+ save_failed: true,
46
+ oversized: true,
47
+ warning: `Response was NOT truncated because the local save failed: ${message}. ` +
48
+ `The complete payload is returned inline.`,
49
+ page,
50
+ };
51
+ // Parse before embedding so callers receive JSON data rather than a
52
+ // JSON string containing escaped JSON. If the caller's text was never
53
+ // valid JSON, fall back to carrying it verbatim as a string — the helper
54
+ // itself must never emit invalid JSON.
55
+ try {
56
+ descriptor.data = JSON.parse(text);
57
+ }
58
+ catch {
59
+ descriptor.data_text = text;
60
+ }
61
+ return JSON.stringify(descriptor);
37
62
  }
38
- return text.slice(0, SFCC_MAX_INLINE) + truncationNote(filePath);
63
+ const descriptor = {
64
+ truncated: true,
65
+ saved_path: filePath,
66
+ page,
67
+ };
68
+ return JSON.stringify(descriptor);
39
69
  }
@@ -82,7 +82,6 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
82
82
  }
83
83
  if (result.ok) {
84
84
  const normalized = normalizeOcapiPage(result.body);
85
- const itemCount = normalized ? normalized.count : "unknown";
86
85
  return {
87
86
  content: [
88
87
  {
@@ -90,7 +89,7 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
90
89
  text: `✓ OCAPI access confirmed.\n` +
91
90
  ` Instance: ${credentials.hostname.split(".")[0]}\n` +
92
91
  ` OCAPI version: ${ocapiVersion}\n` +
93
- ` GET /system_object_definitions: ${itemCount} items returned`,
92
+ ` GET /system_object_definitions: ${formatProbeCount(normalized)}`,
94
93
  },
95
94
  ],
96
95
  };
@@ -116,6 +115,29 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
116
115
  ],
117
116
  };
118
117
  }
118
+ /**
119
+ * Render the probe's count line without overstating what OCAPI reported.
120
+ *
121
+ * The probe reads one page, so the number of records it got back is a page
122
+ * count, not the instance-wide total. Saying "N items returned" made a page of
123
+ * 25 look like an instance that owns exactly 25 system objects. Three distinct
124
+ * cases are now distinguishable:
125
+ *
126
+ * - OCAPI supplied a total → "25 of 73 items returned"
127
+ * - OCAPI omitted the total → the number is labelled as the current page and
128
+ * the missing instance total is stated outright
129
+ * - strict normalization failed → neither number is claimed
130
+ */
131
+ function formatProbeCount(normalized) {
132
+ if (!normalized) {
133
+ return "page count and instance total unavailable (unrecognized response envelope)";
134
+ }
135
+ const { returned, total } = normalized.page;
136
+ if (total !== undefined) {
137
+ return `${returned} of ${total} items returned`;
138
+ }
139
+ return `${returned} items returned on this page (OCAPI did not provide the instance total)`;
140
+ }
119
141
  function ocapiSettingsInstructions(ocapiVersion) {
120
142
  return (`To grant OCAPI access, paste the JSON below in Business Manager:\n` +
121
143
  ` Administration > Site Development > Open Commerce API Settings → Data API tab\n\n` +
@@ -0,0 +1,92 @@
1
+ /**
2
+ * SFCC read-body redaction seam (BAPI-769).
3
+ *
4
+ * `projection: "full"` (BAPI-767) makes several OCAPI Data API endpoints return
5
+ * complete attribute-definition documents — `system_object_attribute_search`,
6
+ * `custom_object_definition_attribute_*`, and `system_object_definitions/{type}/
7
+ * attribute_definitions/{id}`. Live sandbox verification (OCAPI Data API v25_6)
8
+ * showed one of those 27 keys is not schema metadata about the attribute: it is
9
+ * the attribute's own runtime VALUE. `default_value` can carry a real
10
+ * configuration value — an attribute named `llmAvailableModelsJson` can carry
11
+ * actual model-routing config as its default — and that value must never reach
12
+ * an MCP caller, in the inline response, the `docs/tmp/sfcc/` save, or a
13
+ * successful attribute-definition write echo.
14
+ *
15
+ * This module owns exactly that: withholding one named structured field. It
16
+ * deliberately does NOT port `src/python/sfcc_monitor/adapters/redaction.py`'s
17
+ * regex/entropy engine — there is nothing to detect heuristically, only one key
18
+ * to withhold — and it deliberately does NOT redact inside `ocapi-shape.ts`.
19
+ * `normalizeOcapiBody`'s documented invariant is verbatim passthrough; folding
20
+ * redaction into it would make that no longer true. `readSfccBody` composes the
21
+ * two steps explicitly instead: redact first, normalize second.
22
+ *
23
+ * Attribute default values are intentionally unavailable through this MCP
24
+ * surface. Business Manager (Administration → Site Development → an object
25
+ * type's attribute definition) is the supported path to read one.
26
+ */
27
+ import { normalizeOcapiBody } from "./ocapi-shape.js";
28
+ /**
29
+ * Marker substituted for a withheld `default_value` node.
30
+ *
31
+ * Deliberately distinct from the log-query system's own `[REDACTED]` marker
32
+ * (`sfcc/log-query.ts` and friends) — the two redaction seams are unrelated:
33
+ * this one withholds one named structured field before normalization, the log
34
+ * seam performs regex/entropy scrubbing of log text. Conflating the markers
35
+ * would make a caller unable to tell which seam produced a given redaction.
36
+ */
37
+ export const BRIDGE_REDACTED_MARKER = "[REDACTED_BY_BRIDGE]";
38
+ /** True for a plain (non-array, non-null) object. */
39
+ function isPlainObject(value) {
40
+ return typeof value === "object" && value !== null && !Array.isArray(value);
41
+ }
42
+ /**
43
+ * Recursively withhold every `default_value` key found anywhere in `body`.
44
+ *
45
+ * Whenever an object carries an own `default_value` key, that key's ENTIRE
46
+ * value — including the `object_attribute_value_definition` wrapper OCAPI
47
+ * shapes it as (`{ _type, id, position, value }`) — is replaced with
48
+ * `BRIDGE_REDACTED_MARKER`. The key itself is always kept; only its value
49
+ * changes. Recursion continues into every sibling and into the rest of the same
50
+ * object/array, so a document that embeds another attribute-definition-shaped
51
+ * object deeper in its tree is still covered — the live key survey found only
52
+ * one `default_value` per document, but the walker stays general rather than
53
+ * hard-coding that shape.
54
+ *
55
+ * Side-effect-free: `body` is never mutated. A fresh tree is built and
56
+ * returned, so the caller's original object is unchanged after the call. A
57
+ * `default_value` key that was never present on the source is never
58
+ * synthesized — absence stays absence.
59
+ */
60
+ export function redactSfccBody(body) {
61
+ if (Array.isArray(body)) {
62
+ return body.map((item) => redactSfccBody(item));
63
+ }
64
+ if (isPlainObject(body)) {
65
+ const result = {};
66
+ for (const [key, value] of Object.entries(body)) {
67
+ // Object.entries only yields own enumerable properties, so reaching this
68
+ // branch already means `body` owns `default_value` — no key is ever added
69
+ // where the source never had one.
70
+ if (key === "default_value") {
71
+ result[key] = BRIDGE_REDACTED_MARKER;
72
+ continue;
73
+ }
74
+ result[key] = redactSfccBody(value);
75
+ }
76
+ return result;
77
+ }
78
+ return body;
79
+ }
80
+ /**
81
+ * Redact, then normalize, a raw OCAPI response body.
82
+ *
83
+ * This is the read-path replacement for calling `normalizeOcapiBody` directly.
84
+ * Redaction happens BEFORE normalization, serialization, truncation, or save —
85
+ * one placement covers the inline MCP response and the `docs/tmp/sfcc/` save
86
+ * path identically, because both are derived from this same normalized result.
87
+ * `normalizeOcapiBody` itself remains a non-redacting verbatim shaper; it is
88
+ * `readSfccBody` that owns the redaction decision.
89
+ */
90
+ export function readSfccBody(body) {
91
+ return normalizeOcapiBody(redactSfccBody(body));
92
+ }