@zackbart/connecta 0.31.0 → 0.33.0

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 (169) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +30 -20
  3. package/bin/connecta.mjs +25 -6
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.d.ts +167 -23
  6. package/dist/access-tokens.js +676 -140
  7. package/dist/agent-identity.js +47 -0
  8. package/dist/auth/cloudflare-access.js +13 -5
  9. package/dist/auth-elicitation.js +1 -1
  10. package/dist/call-admission-context.js +15 -0
  11. package/dist/call-admission.js +30 -43
  12. package/dist/call-recovery.js +6 -2
  13. package/dist/catalog-service.js +7 -2
  14. package/dist/config-value-sources.js +14 -9
  15. package/dist/config.d.ts +58 -78
  16. package/dist/config.js +143 -47
  17. package/dist/connector-access.d.ts +4 -33
  18. package/dist/connector-access.js +15 -99
  19. package/dist/connector-caller.d.ts +7 -1
  20. package/dist/connector-caller.js +3 -2
  21. package/dist/connectors/api-connector.d.ts +27 -4
  22. package/dist/connectors/api-connector.js +2 -0
  23. package/dist/connectors/guarded-fetch.d.ts +9 -0
  24. package/dist/connectors/guarded-fetch.js +7 -0
  25. package/dist/connectors/remote-mcp.js +2 -0
  26. package/dist/describe-config.d.ts +32 -7
  27. package/dist/describe-config.js +9 -18
  28. package/dist/errors.d.ts +1 -1
  29. package/dist/errors.js +1 -0
  30. package/dist/execute.js +16 -14
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +12 -7
  34. package/dist/membership.d.ts +51 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/meta-tools.js +17 -19
  37. package/dist/module-contracts.d.ts +23 -6
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -2
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/contract.d.ts +151 -29
  43. package/dist/operator-ui/endpoints.js +192 -0
  44. package/dist/operator-ui/fix-prompts.js +4 -4
  45. package/dist/operator-ui/generated.js +5 -4
  46. package/dist/operator-ui/member-page.js +112 -0
  47. package/dist/operator-ui/model.d.ts +15 -7
  48. package/dist/operator-ui/view.d.ts +84 -19
  49. package/dist/operator-ui/view.js +83 -21
  50. package/dist/page-styles.js +1 -1
  51. package/dist/program-writes.js +2 -2
  52. package/dist/provider.d.ts +63 -11
  53. package/dist/provider.js +46 -7
  54. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  55. package/dist/providers/_shared/google/workspace.js +31 -9
  56. package/dist/providers/_shared/rest/tools.js +2 -2
  57. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  58. package/dist/providers/_shared/rest/value-safety.js +699 -7
  59. package/dist/providers/breeze/skill.generated.js +2 -2
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +3 -3
  62. package/dist/providers/cloudflare/rest.js +25 -3
  63. package/dist/providers/cloudflare/skill.generated.js +2 -2
  64. package/dist/providers/cloudflare/value-safety.js +32 -0
  65. package/dist/providers/docs/index.d.ts +1 -1
  66. package/dist/providers/docs/index.js +5 -5
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.d.ts +1 -1
  69. package/dist/providers/drive/index.js +4 -4
  70. package/dist/providers/drive/skill.generated.js +2 -2
  71. package/dist/providers/forms/index.d.ts +1 -1
  72. package/dist/providers/forms/index.js +5 -5
  73. package/dist/providers/forms/skill.generated.js +2 -2
  74. package/dist/providers/github/skill.generated.js +2 -2
  75. package/dist/providers/gmail/index.d.ts +1 -1
  76. package/dist/providers/gmail/index.js +279 -26
  77. package/dist/providers/gmail/skill.generated.js +2 -2
  78. package/dist/providers/linear/index.js +1 -1
  79. package/dist/providers/mixpanel/index.d.ts +27 -12
  80. package/dist/providers/mixpanel/index.js +58 -6
  81. package/dist/providers/mixpanel/operations.js +20 -0
  82. package/dist/providers/mixpanel/rest.js +603 -0
  83. package/dist/providers/mixpanel/skill.generated.js +3 -2
  84. package/dist/providers/mixpanel/value-safety.js +16 -0
  85. package/dist/providers/notion/index.d.ts +7 -0
  86. package/dist/providers/notion/index.js +116 -11
  87. package/dist/providers/notion/rest.js +183 -24
  88. package/dist/providers/notion/skill.generated.js +4 -4
  89. package/dist/providers/notion/value-safety.js +30 -5
  90. package/dist/providers/overflow/index.js +1 -1
  91. package/dist/providers/planning-center/index.d.ts +27 -0
  92. package/dist/providers/planning-center/index.js +287 -25
  93. package/dist/providers/planning-center/skill.generated.js +24 -4
  94. package/dist/providers/sheets/index.d.ts +4 -1
  95. package/dist/providers/sheets/index.js +9 -6
  96. package/dist/providers/sheets/skill.generated.js +2 -2
  97. package/dist/providers/slides/index.d.ts +1 -1
  98. package/dist/providers/slides/index.js +6 -6
  99. package/dist/providers/slides/skill.generated.js +2 -2
  100. package/dist/providers/stripe/rest.js +9 -2
  101. package/dist/providers/stripe/skill.generated.js +2 -2
  102. package/dist/providers/stripe/value-safety.js +22 -0
  103. package/dist/providers/vercel/index.js +2 -2
  104. package/dist/providers/vercel/rest.js +20 -2
  105. package/dist/providers/vercel/skill.generated.js +2 -2
  106. package/dist/providers/vercel/value-safety.js +22 -0
  107. package/dist/providers/wispr-flow/index.d.ts +14 -0
  108. package/dist/providers/wispr-flow/index.js +117 -0
  109. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  110. package/dist/registry.d.ts +2 -3
  111. package/dist/registry.js +8 -9
  112. package/dist/routes/access-tokens.js +82 -75
  113. package/dist/routes/activity-disclosure.js +17 -18
  114. package/dist/routes/activity.js +34 -54
  115. package/dist/routes/actor-labels.js +33 -4
  116. package/dist/routes/agent-path.js +43 -0
  117. package/dist/routes/connect.js +16 -12
  118. package/dist/routes/credentials.js +16 -40
  119. package/dist/routes/mcp.js +155 -85
  120. package/dist/routes/oauth-management.js +11 -5
  121. package/dist/routes/oauth.js +3 -9
  122. package/dist/routes/operator.js +25 -17
  123. package/dist/routes/shared.d.ts +60 -20
  124. package/dist/routes/shared.js +187 -96
  125. package/dist/routes/ui-config.js +437 -97
  126. package/dist/routes/ui-data.js +2 -2
  127. package/dist/routes/ui.js +9 -16
  128. package/dist/runtime/call-admission.js +9 -0
  129. package/dist/server.js +8 -0
  130. package/dist/skills.js +5 -4
  131. package/dist/storage/keys.js +9 -1
  132. package/dist/tool-safety.js +13 -5
  133. package/dist/types.d.ts +12 -1
  134. package/dist/ui.d.ts +2 -2
  135. package/dist/ui.js +17 -15
  136. package/dist/usage-guide.js +3 -3
  137. package/dist/version.d.ts +1 -1
  138. package/dist/version.js +1 -1
  139. package/dist/workspace-address.js +9 -0
  140. package/dist/workspace-subject.js +23 -0
  141. package/documentation/README.md +46 -22
  142. package/documentation/architecture.md +378 -58
  143. package/documentation/auth.md +806 -215
  144. package/documentation/code-mode.md +93 -81
  145. package/documentation/deploying.md +128 -26
  146. package/documentation/integrating.md +145 -22
  147. package/documentation/meta-tools.md +58 -46
  148. package/documentation/migration-0.33.md +344 -0
  149. package/documentation/operating.md +14 -9
  150. package/documentation/operator-tests.md +117 -31
  151. package/documentation/operator-ui.md +326 -55
  152. package/documentation/provider-auth-migration.md +42 -27
  153. package/documentation/provider-migration-0.29.md +3 -3
  154. package/examples/worker/AGENTS.md +35 -8
  155. package/examples/worker/README.md +92 -53
  156. package/examples/worker/src/connecta.config.ts +41 -20
  157. package/examples/worker/wrangler.jsonc +3 -0
  158. package/package.json +7 -2
  159. package/templates/node/.env.example +3 -0
  160. package/templates/node/AGENTS.md +33 -10
  161. package/templates/node/Dockerfile +2 -2
  162. package/templates/node/README.md +71 -44
  163. package/templates/node/docker-compose.yml +3 -2
  164. package/templates/node/package.json +4 -3
  165. package/templates/node/src/adopt-token.ts +23 -0
  166. package/templates/node/src/connecta.config.ts +33 -12
  167. package/templates/node/src/rotate-token.ts +17 -0
  168. package/dist/tool-safety.d.ts +0 -9
  169. package/templates/node/src/provision-token.ts +0 -17
@@ -1,3 +1,40 @@
1
+ // Value safety for every REST vendor (decision 0005): no tool response,
2
+ // success or error, returns a credential (API, service, or bypass tokens,
3
+ // signing secrets, bearer URLs, private keys, temporary credentials, auth
4
+ // headers, authorization verifiers) or a stored secret value (environment
5
+ // variables, secret items), unless a reviewed named tool exists to return it.
6
+ //
7
+ // One mechanism, strongest layer first:
8
+ //
9
+ // 1. Refuse by operation. `scripts/value-safety.mjs` flags every operation in
10
+ // a vendor's pinned spec whose response may carry one; `providers:spec`
11
+ // commits the result as `value-safety.candidates.json`. Each needs a
12
+ // reviewed verdict in the vendor's `value-safety.ts` table, and the shared
13
+ // harness (`test/fixtures/value-safety.ts`) fails on any flagged operation
14
+ // without one, a stale pin, a verdict for an operation the index lacks, a
15
+ // flagged field no verdict accounts for, or a refusal that reaches
16
+ // transport.
17
+ // 2. Reviewed field paths for `redact` verdicts, on every success body the
18
+ // vendor returns (generic tools, HEAD data, named tools, uploads, logs),
19
+ // after any envelope unwrap and before cursors or `select` read it; then
20
+ // resource rules on every object whose type discriminator they name,
21
+ // wherever an expansion, list, or event embeds it.
22
+ // 3. A key-name heuristic, defense in depth only, over every body: any
23
+ // subtree under a credential-named key, labelled or typed secret records,
24
+ // environment containers, header rules, and URLs with userinfo or
25
+ // credential query parameters.
26
+ // 4. Errors: an operation with a verdict, or whose request accepts a
27
+ // credential-named field, answers failures with the vendor's codes and
28
+ // statuses but never its text (`withholdsErrors`), unless its review
29
+ // found its errors cannot echo a stored secret (`vendorErrors`).
30
+ //
31
+ // Residual risk, as with Infisical: a secret a person typed into a free-text
32
+ // name, description, log line, or identifier is out of scope.
33
+ //
34
+ // The key-name rules combine Cloudflare's and Vercel's former engines.
35
+ // URL policy and exemptions are reviewed per vendor in its table (`keep`,
36
+ // `fields`, `scopeMaps`, `envBodies`, `embeddedUrls`).
37
+ import { ConnectorCallError } from "../../../errors.js";
1
38
  /** What a removed secret reads as. */
2
39
  export const REDACTED = "[redacted]";
3
40
  /** Credential vocabulary over normalized names, matched anywhere; mirrors `scripts/value-safety.mjs`. */
@@ -159,7 +196,7 @@ function isRecord(value) {
159
196
  return typeof value === "object" && value !== null && !Array.isArray(value);
160
197
  }
161
198
  /** A URL without userinfo, credential query parameters, or a credential-looking fragment; anything unparseable is withheld. */
162
- function sanitizeUrl(value) {
199
+ function sanitizeUrl(value, credentials = true) {
163
200
  let url;
164
201
  try {
165
202
  url = new URL(value);
@@ -174,19 +211,624 @@ function sanitizeUrl(value) {
174
211
  changed = true;
175
212
  }
176
213
  // A snapshot of the names: setting a parameter while iterating the live list is unsafe.
177
- for (const name of Array.from(url.searchParams.keys())) {
214
+ for (const name of credentials ? Array.from(url.searchParams.keys()) : []) {
178
215
  if (URL_PARAM_CONTAINS.test(name) || URL_PARAM_SUFFIX.test(name.toLowerCase().replace(/[_-]/g, ""))) {
179
216
  url.searchParams.set(name, REDACTED);
180
217
  changed = true;
181
218
  }
182
219
  }
183
- if (url.hash && /token|secret|key|code|auth/i.test(url.hash)) {
220
+ if (credentials && url.hash && /token|secret|key|code|auth/i.test(url.hash)) {
184
221
  url.hash = "";
185
222
  changed = true;
186
223
  }
187
224
  // An ordinary URL comes back exactly as the vendor sent it.
188
225
  return changed ? url.toString() : value;
189
226
  }
227
+ /** Signing parameters that are a credential in any URL, with their `=` plain or encoded. */
228
+ const SIGNING_PARAM = /x-(?:amz|goog)-(?:signature|credential|security-token)(?:=|%(?:25)*3d|&#0*61;?|&#x0*3d;?|=?|\\u003d)/gi;
229
+ /**
230
+ * One encoded character at the start of link text: a percent escape, an HTML
231
+ * character reference, a `\u` or `\x` escape, or a Markdown backslash escape.
232
+ */
233
+ const LEADING_ESCAPE = /^(?:%([0-9a-f]{2})|&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|(amp|quot|apos|lt|gt|quest|num|semi|equals|sol|percnt|bsol));?|\\u([0-9a-f]{4})|\\x([0-9a-f]{2})|\\)/i;
234
+ /** One encoded character just before a host: the same escapes, read backwards over a few characters. */
235
+ const TRAILING_ESCAPE = /(?:%(?:25){0,4}([0-9a-f]{2})|&(?:#0{0,6}(\d{1,7})|#x0{0,6}([0-9a-f]{1,6})|([a-z]{2,6}));?|\\u([0-9a-f]{4})|\\x([0-9a-f]{2})|\\)$/i;
236
+ /** Every escape in link text, decoded together; an unknown entity name stays as written. */
237
+ const ESCAPES = /%([0-9a-f]{2})|&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z]{2,8}));?|\\u([0-9a-f]{4})|\\x([0-9a-f]{2})|\\(?=[^a-z0-9])/gi;
238
+ /** One slash however it is spelled, read forwards. */
239
+ const SLASH = /\/|\\\/|%(?:25){0,4}2f|&#0{0,6}47;?|&#x0{0,6}2f;?|/?|\\u002f|\\x2f/iy;
240
+ /** An S3 endpoint host: global, regional, dual-stack, or accelerated. */
241
+ const S3_ENDPOINT = String.raw `s3(?:[.-](?:dualstack|accelerate|external-1|[a-z]{2}(?:-[a-z]{1,12}){1,3}-\d{1,2})){0,2}\.amazonaws\.com`;
242
+ const NAMED = {
243
+ amp: "&",
244
+ colon: ":",
245
+ quot: '"',
246
+ apos: "'",
247
+ lt: "<",
248
+ gt: ">",
249
+ quest: "?",
250
+ num: "#",
251
+ semi: ";",
252
+ equals: "=",
253
+ sol: "/",
254
+ percnt: "%",
255
+ bsol: "\\",
256
+ lbrack: "[",
257
+ rbrack: "]",
258
+ lsqb: "[",
259
+ rsqb: "]",
260
+ period: ".",
261
+ };
262
+ /** A host name's label character: the subdomain labels before a reviewed name are these alone. */
263
+ const LABEL = /[a-z0-9.-]/i;
264
+ /** The longest host name DNS allows: the bound on authority inspection. */
265
+ const MAX_HOST = 253;
266
+ /** The longest scheme read back from its `:`. */
267
+ const MAX_SCHEME = 32;
268
+ const SCHEME_CHAR = /[a-z0-9+.-]/i;
269
+ /** What may open a scheme-less `//host`: whitespace or punctuation that opens a link, value, or attribute. */
270
+ const OPENS_LINK = /[\s"'`(<[{=,;|]/;
271
+ /** Punctuation that may close the syntax around a link; it carries no secret, so it comes back. */
272
+ const TRAILING = /[)\]}.,;:!*_~`\\]/;
273
+ /** Code points past Unicode's range decode to nothing rather than throw. */
274
+ function codePoint(value) {
275
+ return value <= 0x10ffff ? String.fromCodePoint(value) : "";
276
+ }
277
+ /** The character an escape's groups spell: percent, decimal, hex, name, `\u`, `\x`; a bare backslash spells nothing. */
278
+ function unescaped(groups) {
279
+ const [percent, decimal, hex, name, unicode, byte] = groups;
280
+ if (percent !== undefined)
281
+ return String.fromCharCode(Number.parseInt(percent, 16));
282
+ if (decimal !== undefined)
283
+ return codePoint(Number(decimal));
284
+ if (hex !== undefined)
285
+ return codePoint(Number.parseInt(hex, 16));
286
+ if (name !== undefined)
287
+ return NAMED[name.toLowerCase()];
288
+ if (unicode !== undefined || byte !== undefined)
289
+ return String.fromCharCode(Number.parseInt((unicode ?? byte), 16));
290
+ return "";
291
+ }
292
+ /**
293
+ * Whether link text opens its query or fragment here once decoded as a
294
+ * browser, a Markdown renderer, or a JSON parser would: `%3F`, `%253F`,
295
+ * `&#63;`, `&amp;#35;`, `?`. Only the leading character matters, so
296
+ * each round decodes one escape there: constant work.
297
+ */
298
+ function opener(text, at) {
299
+ let current = text.slice(at, at + 32);
300
+ for (let round = 0; round < 4; round++) {
301
+ if (current[0] === "?" || current[0] === "#")
302
+ return current[0];
303
+ const escape = LEADING_ESCAPE.exec(current);
304
+ if (!escape)
305
+ return undefined;
306
+ current = (unescaped(escape.slice(1)) ?? "") + current.slice(escape[0].length);
307
+ }
308
+ return current[0] === "?" || current[0] === "#" ? current[0] : undefined;
309
+ }
310
+ /** The character ending just before `at`, decoded if it is escaped, and how many characters spell it. */
311
+ function charBefore(text, at) {
312
+ const escape = TRAILING_ESCAPE.exec(text.slice(Math.max(0, at - 16), at));
313
+ const char = escape ? unescaped(escape.slice(1)) : undefined;
314
+ if (escape && char)
315
+ return { char, length: escape[0].length };
316
+ // A JSON-escaped slash, `\/`, is one slash.
317
+ if (text[at - 1] === "/" && text[at - 2] === "\\")
318
+ return { char: "/", length: 2 };
319
+ return { char: text[at - 1], length: 1 };
320
+ }
321
+ /**
322
+ * Where the link whose authority opens at `at` starts, or `-1` when no
323
+ * authority opens there: `//` however spelled, after a scheme's `:`
324
+ * (`https:`, `https%3A`), the text's start, or what opens a scheme-less
325
+ * link (`src=//`, ` //`). A `//` after anything else (`example.com//`) is
326
+ * part of a path.
327
+ */
328
+ function authorityAt(text, at) {
329
+ if (at === 0)
330
+ return -1;
331
+ const second = charBefore(text, at);
332
+ const between = at - second.length;
333
+ if (second.char !== "/" || between <= 0)
334
+ return -1;
335
+ const first = charBefore(text, between);
336
+ const slashes = between - first.length;
337
+ if (first.char !== "/" || slashes < 0)
338
+ return -1;
339
+ if (slashes === 0)
340
+ return 0;
341
+ const before = charBefore(text, slashes);
342
+ if (before.char !== ":")
343
+ return OPENS_LINK.test(before.char) ? slashes : -1;
344
+ const colon = slashes - before.length;
345
+ let start = colon;
346
+ while (start > 0 && colon - start < MAX_SCHEME && SCHEME_CHAR.test(text[start - 1]))
347
+ start--;
348
+ return start < colon && (start === 0 || OPENS_LINK.test(text[start - 1])) ? start : -1;
349
+ }
350
+ /** Where a host's optional trailing dot and port end after `at`, or `-1` when a `:` holds no port (userinfo's password). */
351
+ function afterPort(text, at) {
352
+ if (text[at] === ".")
353
+ at++;
354
+ if (text[at] !== ":")
355
+ return at;
356
+ let digits = at + 1;
357
+ while (digits - at <= 5 && /\d/.test(text[digits] ?? ""))
358
+ digits++;
359
+ return digits > at + 1 ? digits : -1;
360
+ }
361
+ /**
362
+ * Whether a host or path segment ends at `at`: a slash, query, or fragment
363
+ * however spelled, or the link's end. Never another
364
+ * name character or an `@`: `file.notion.so@evil.com` names userinfo, and
365
+ * `prod-files-secure@other` and `prod-files-securex` are other segments.
366
+ */
367
+ function boundary(text, at) {
368
+ if (at < 0)
369
+ return false;
370
+ if (at >= text.length)
371
+ return true;
372
+ if (endsLink(text.charCodeAt(at), text[at]))
373
+ return true;
374
+ SLASH.lastIndex = at;
375
+ return SLASH.test(text) || opener(text, at) !== undefined;
376
+ }
377
+ /** Whether the `#` at `at` belongs to a numeric character reference (`&#38;`) rather than opening a fragment. */
378
+ function reference(text, at) {
379
+ return text.charCodeAt(at - 1) === 0x26 && /[0-9x]/i.test(text[at + 1] ?? "");
380
+ }
381
+ /** Whitespace, a quote, or an angle bracket: where a link in text ends, read fail-closed. */
382
+ function endsLink(code, char) {
383
+ if (code <= 0x20 || code === 0x22 || code === 0x27 || code === 0x3c || code === 0x3e)
384
+ return true;
385
+ return code > 0x7f && /\s/.test(char);
386
+ }
387
+ /** Where a signing parameter's value ends: its link's end, or the next parameter or closing bracket. */
388
+ function endsValue(text, at) {
389
+ const code = text.charCodeAt(at);
390
+ if (endsLink(code, text[at]))
391
+ return true;
392
+ if (code === 0x26 || code === 0x23 || code === 0x29 || code === 0x5d || code === 0x60)
393
+ return true;
394
+ // `\/` is a JSON-escaped slash inside the value; any other backslash escapes what follows it.
395
+ return code === 0x5c && text.charCodeAt(at + 1) !== 0x2f;
396
+ }
397
+ const HOST_MATCHERS = new WeakMap();
398
+ function hostMatcher(hosts) {
399
+ let matcher = HOST_MATCHERS.get(hosts);
400
+ if (!matcher) {
401
+ matcher = {
402
+ endpoint: new RegExp(`^${S3_ENDPOINT}$`, "i"),
403
+ domains: hosts.domains.map((host) => host.toLowerCase()),
404
+ buckets: hosts.buckets.map((bucket) => bucket.toLowerCase()),
405
+ };
406
+ HOST_MATCHERS.set(hosts, matcher);
407
+ }
408
+ return matcher;
409
+ }
410
+ /** The end of a reviewed file authority, read only at the current URL's host. */
411
+ function fileHost(text, start, matcher) {
412
+ let end = start;
413
+ while (end < text.length && end - start <= MAX_HOST && LABEL.test(text[end]))
414
+ end++;
415
+ if (end === start || end - start > MAX_HOST)
416
+ return undefined;
417
+ const port = afterPort(text, end);
418
+ if (!boundary(text, port))
419
+ return undefined;
420
+ const name = text.slice(start, end).toLowerCase().replace(/\.$/, "");
421
+ if (matcher.domains.some((domain) => name === domain || name.endsWith(`.${domain}`)))
422
+ return end;
423
+ if (matcher.buckets.some((bucket) => name.startsWith(`${bucket}.`) && matcher.endpoint.test(name.slice(bucket.length + 1)))) {
424
+ return end;
425
+ }
426
+ if (!matcher.endpoint.test(name))
427
+ return undefined;
428
+ SLASH.lastIndex = port;
429
+ if (!SLASH.test(text))
430
+ return undefined;
431
+ const segment = SLASH.lastIndex;
432
+ for (const bucket of matcher.buckets) {
433
+ const after = segment + bucket.length;
434
+ if (text.slice(segment, after).toLowerCase() === bucket && boundary(text, after))
435
+ return after;
436
+ }
437
+ return undefined;
438
+ }
439
+ /** The authority immediately after a destination's scheme and slashes, however escaped. */
440
+ function destinationHost(text, start) {
441
+ // A scheme and three escaped characters (colon and slashes), each bounded
442
+ // as in charBefore. Never search a destination's path or query for another.
443
+ for (let at = start; at < Math.min(text.length, start + MAX_SCHEME + 48); at++) {
444
+ SLASH.lastIndex = at;
445
+ if (!SLASH.test(text))
446
+ continue;
447
+ const next = SLASH.lastIndex;
448
+ SLASH.lastIndex = next;
449
+ if (!SLASH.test(text))
450
+ return undefined;
451
+ const host = SLASH.lastIndex;
452
+ return authorityAt(text, host) === start ? host : undefined;
453
+ }
454
+ return undefined;
455
+ }
456
+ /** CommonMark permits spaces/tabs and at most one line ending before a destination. */
457
+ function markdownStart(text, at) {
458
+ let line = false;
459
+ for (; at < text.length; at++) {
460
+ const char = text[at];
461
+ if (char === " " || char === "\t")
462
+ continue;
463
+ if (char !== "\r" && char !== "\n")
464
+ return at;
465
+ if (line)
466
+ return undefined;
467
+ line = true;
468
+ if (char === "\r" && text[at + 1] === "\n")
469
+ at++;
470
+ }
471
+ return at;
472
+ }
473
+ /**
474
+ * Read a destination once. Admission ends at its actual CommonMark close,
475
+ * counting balanced and escaped parentheses. Redaction with a query keeps
476
+ * its separate fail-closed span through whitespace, a quote, or an angle
477
+ * bracket, since parentheses can also be part of a signed value.
478
+ */
479
+ function linkSpan(text, start, host, inline, redact) {
480
+ let depth = 0;
481
+ let escaped = false;
482
+ let cut = -1;
483
+ let open = "?";
484
+ let end = start;
485
+ for (; end < text.length; end++) {
486
+ const code = text.charCodeAt(end);
487
+ if (endsLink(code, text[end]))
488
+ break;
489
+ if (inline) {
490
+ if (escaped)
491
+ escaped = false;
492
+ else if (code === 0x5c)
493
+ escaped = true;
494
+ else if (code === 0x28)
495
+ depth++;
496
+ else if (code === 0x29 && depth-- === 0)
497
+ break;
498
+ }
499
+ if (host === undefined || end < host || cut >= 0)
500
+ continue;
501
+ if (code === 0x3f || (code === 0x23 && !reference(text, end))) {
502
+ cut = end;
503
+ open = text[end];
504
+ }
505
+ else if (code === 0x25 || code === 0x26 || code === 0x5c) {
506
+ const opens = opener(text, end);
507
+ if (opens) {
508
+ cut = end;
509
+ open = opens;
510
+ }
511
+ }
512
+ }
513
+ if (redact && cut >= 0) {
514
+ while (end < text.length && !endsLink(text.charCodeAt(end), text[end]))
515
+ end++;
516
+ }
517
+ return { cut, open, end };
518
+ }
519
+ /**
520
+ * Scan destinations, including ones at other hosts, so an outer URL owns
521
+ * every byte of its path and query. A nested scheme or // cannot re-anchor
522
+ * file-host matching. Each consumed span is skipped in full; admission and
523
+ * redaction choose their own span boundary, and both stay linear.
524
+ */
525
+ function* hostLinks(text, hosts, redact) {
526
+ const matcher = hosts ? hostMatcher(hosts) : undefined;
527
+ // The first URL delimiter in the current text run also protects relative
528
+ // paths/queries such as /proxy/https://… and image?url=//… .
529
+ let firstReference = -1;
530
+ for (let at = 0; at < text.length; at++) {
531
+ const char = text[at];
532
+ if (endsLink(text.charCodeAt(at), char)) {
533
+ firstReference = -1;
534
+ continue;
535
+ }
536
+ if (char === "]" && text[at + 1] === "(") {
537
+ const destination = markdownStart(text, at + 2);
538
+ if (destination !== undefined) {
539
+ const angle = text[destination] === "<";
540
+ const start = destination + (angle ? 1 : 0);
541
+ const authority = destinationHost(text, start);
542
+ // Generic policy inspects a relative destination's own query/fragment.
543
+ // Host policy must still attribute the leading authority to the vendor.
544
+ const host = matcher
545
+ ? authority === undefined
546
+ ? undefined
547
+ : fileHost(text, authority, matcher)
548
+ : (authority ?? start);
549
+ const span = linkSpan(text, start, host, !angle, redact);
550
+ at = Math.max(destination, span.end) - 1;
551
+ firstReference = -1;
552
+ if (host !== undefined)
553
+ yield span;
554
+ continue;
555
+ }
556
+ }
557
+ if (char === "/" || char === "\\" || char === "%" || char === "&") {
558
+ SLASH.lastIndex = at;
559
+ if (SLASH.test(text)) {
560
+ const next = SLASH.lastIndex;
561
+ SLASH.lastIndex = next;
562
+ if (SLASH.test(text)) {
563
+ const authority = SLASH.lastIndex;
564
+ const start = authorityAt(text, authority);
565
+ // Global parameter scanning needs no host attribution. This also
566
+ // catches a URL after a log label such as `download:https://…`.
567
+ // File-host policy keeps the stricter authority/proxy boundary.
568
+ if (!matcher || (start >= 0 && (firstReference < 0 || firstReference >= start))) {
569
+ const host = matcher ? fileHost(text, authority, matcher) : authority;
570
+ const span = linkSpan(text, authority, host, false, redact);
571
+ at = span.end - 1;
572
+ firstReference = -1;
573
+ if (host !== undefined)
574
+ yield span;
575
+ continue;
576
+ }
577
+ }
578
+ if (firstReference < 0)
579
+ firstReference = at;
580
+ }
581
+ }
582
+ if (firstReference < 0 && (char === ":" || char === "?" || (char === "#" && !reference(text, at)))) {
583
+ firstReference = at;
584
+ }
585
+ }
586
+ }
587
+ /**
588
+ * `text` with every pre-signed link withheld: at one of `hosts` (Notion's
589
+ * file storage, say), its whole query and fragment; anywhere, a signing
590
+ * parameter's value. Everything else comes back byte for byte.
591
+ */
592
+ export function withholdSignedLinks(text, hosts) {
593
+ return withholdSigningParams(withholdLinkQueries(text, hosts));
594
+ }
595
+ /** A credential-named parameter in a URL, rather than an assignment in prose. */
596
+ function credentialQuery(text) {
597
+ let decoded = decodeLinkText(text, false);
598
+ // Decode the destination's enclosing percent layers only until its own
599
+ // opener is visible. Encoded &/# inside a proxy parameter remain data,
600
+ // rather than becoming parameters or a fragment of the outer URL.
601
+ for (let round = 0; round < 4 && decoded[0] !== "?" && decoded[0] !== "#"; round++) {
602
+ const next = decoded.replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
603
+ if (next === decoded)
604
+ break;
605
+ decoded = next;
606
+ }
607
+ const fragment = decoded.indexOf("#");
608
+ if (fragment >= 0 && /token|secret|key|code|auth/i.test(decodeLinkText(decoded.slice(fragment + 1))))
609
+ return true;
610
+ const query = fragment < 0 ? decoded : decoded.slice(0, fragment);
611
+ for (let at = query[0] === "?" ? 1 : 0; at < query.length;) {
612
+ const next = query.indexOf("&", at);
613
+ const end = next < 0 ? query.length : next;
614
+ const pair = decodeLinkText(query.slice(at, end));
615
+ const equals = pair.indexOf("=");
616
+ at = end + 1;
617
+ if (equals < 0)
618
+ continue;
619
+ const name = pair.slice(0, equals);
620
+ if (!URL_PARAM_CONTAINS.test(name) && !URL_PARAM_SUFFIX.test(normalizedName(name)))
621
+ continue;
622
+ if (pair.slice(equals + 1) !== REDACTED)
623
+ return true;
624
+ }
625
+ return false;
626
+ }
627
+ /** Remove a link's entire query/fragment, keeping its surrounding syntax. */
628
+ function withholdLinkQueries(text, hosts) {
629
+ let out = "";
630
+ let copied = 0;
631
+ for (const { cut, open, end } of hostLinks(text, hosts, hosts !== undefined)) {
632
+ if (cut < 0)
633
+ continue;
634
+ if (!hosts && !credentialQuery(text.slice(cut, end)))
635
+ continue;
636
+ let keep = end;
637
+ while (keep > cut + 1 && TRAILING.test(text[keep - 1])) {
638
+ // A signing pass may already have replaced the final parameter. Its
639
+ // closing bracket belongs to the value, not the surrounding Markdown.
640
+ if (text.endsWith(REDACTED, keep))
641
+ break;
642
+ keep--;
643
+ }
644
+ // A query with nothing in it.
645
+ if (keep <= cut + 1)
646
+ continue;
647
+ out += `${text.slice(copied, cut)}${open}${REDACTED}`;
648
+ copied = keep;
649
+ }
650
+ return copied === 0 ? text : out + text.slice(copied);
651
+ }
652
+ function withholdSigningParams(anchored) {
653
+ let out = "";
654
+ let copied = 0;
655
+ SIGNING_PARAM.lastIndex = 0;
656
+ for (let match = SIGNING_PARAM.exec(anchored); match; match = SIGNING_PARAM.exec(anchored)) {
657
+ const start = match.index + match[0].length;
658
+ // Skip an existing complete placeholder, but still remove any suffix.
659
+ const already = anchored.startsWith(REDACTED, start);
660
+ let end = already ? start + REDACTED.length : start;
661
+ while (end < anchored.length && !endsValue(anchored, end))
662
+ end++;
663
+ SIGNING_PARAM.lastIndex = end;
664
+ if (end === start || (already && end === start + REDACTED.length))
665
+ continue;
666
+ out += `${anchored.slice(copied, start)}${REDACTED}`;
667
+ copied = end;
668
+ }
669
+ return copied === 0 ? anchored : out + anchored.slice(copied);
670
+ }
671
+ /**
672
+ * Reviewed REST text policy: only credential-bearing URL queries/fragments
673
+ * change. Ordinary links, including external proxies, retain their bytes.
674
+ * Literal AWS/Google signing names are withheld anywhere, as in #818.
675
+ */
676
+ export function withholdEmbeddedUrls(text) {
677
+ return withholdSigningParams(withholdLinkQueries(text));
678
+ }
679
+ /** Every string inside a JSON value through `withholdSignedLinks`; unchanged values keep their identity. */
680
+ export function withholdSignedLinksIn(value, hosts, depth = 0) {
681
+ return mapStrings(value, (text) => withholdSignedLinks(text, hosts), depth);
682
+ }
683
+ /** Apply the reviewed text policy to a selected value or vendor error body. */
684
+ export function withholdEmbeddedUrlsIn(value) {
685
+ return mapStrings(value, withholdEmbeddedUrls, 0);
686
+ }
687
+ function mapStrings(value, cleanText, depth) {
688
+ if (typeof value === "string")
689
+ return cleanText(value);
690
+ if (typeof value !== "object" || value === null)
691
+ return value;
692
+ // Deeper than any vendor's shape: withheld, as the heuristic does.
693
+ if (depth > MAX_DEPTH)
694
+ return REDACTED;
695
+ if (Array.isArray(value)) {
696
+ let changed = false;
697
+ const items = value.map((item) => {
698
+ const clean = mapStrings(item, cleanText, depth + 1);
699
+ if (clean !== item)
700
+ changed = true;
701
+ return clean;
702
+ });
703
+ return changed ? items : value;
704
+ }
705
+ let changed = false;
706
+ const entries = Object.entries(value).map(([key, item]) => {
707
+ const clean = mapStrings(item, cleanText, depth + 1);
708
+ if (clean !== item)
709
+ changed = true;
710
+ return [key, clean];
711
+ });
712
+ return changed ? Object.fromEntries(entries) : value;
713
+ }
714
+ /**
715
+ * Link text decoded as a browser, a Markdown renderer, or a JSON parser
716
+ * would, through up to four layers of escaping (`%255B`, `&amp;#91;`). Each
717
+ * round is one linear pass.
718
+ */
719
+ function decodeLinkText(text, percent = true) {
720
+ for (let round = 0; round < 4; round++) {
721
+ const next = text.replace(ESCAPES, (token, ...groups) => !percent && groups[0] !== undefined ? token : token === "\\" ? "" : (unescaped(groups.slice(0, 6)) ?? token));
722
+ if (next === text)
723
+ break;
724
+ text = next;
725
+ }
726
+ return text;
727
+ }
728
+ /**
729
+ * Whether a request value carries a link whose query or
730
+ * fragment holds the exact `[redacted]` placeholder, plain or encoded: a
731
+ * withheld read copied into a write, which would store the placeholder.
732
+ * Only the link's own destination is read: a Markdown link ends at its
733
+ * closing `)`, so `[f](…?a=1)[redacted]` is ordinary text after it.
734
+ * The word alone (`unredacted`) or the placeholder away from a file link is
735
+ * a person's text. When `hosts` is supplied, require its actual authority
736
+ * to match that policy, preserving Notion's external-proxy boundary.
737
+ */
738
+ export function carriesWithheldLink(value, hosts) {
739
+ const pending = [{ value }];
740
+ const ancestors = new WeakSet();
741
+ const inspect = (text) => {
742
+ for (const { cut, end } of hostLinks(text, hosts, false)) {
743
+ if (cut >= 0 && decodeLinkText(text.slice(cut, end)).includes(REDACTED))
744
+ return true;
745
+ }
746
+ return false;
747
+ };
748
+ // JSON structure is bounded by the admitted request's bytes. Visit every key
749
+ // and value iteratively, without imposing a smaller node/depth limit. Reject
750
+ // cycles from direct callers; repeated non-cyclic references remain valid.
751
+ while (pending.length > 0) {
752
+ const { value: item, leave } = pending.pop();
753
+ if (typeof item === "string") {
754
+ if (inspect(item))
755
+ return true;
756
+ }
757
+ else if (typeof item === "object" && item !== null) {
758
+ if (leave) {
759
+ ancestors.delete(item);
760
+ continue;
761
+ }
762
+ if (ancestors.has(item)) {
763
+ throw new ConnectorCallError("invalid_args", "Not sent: URL admission cannot inspect cyclic JSON.");
764
+ }
765
+ ancestors.add(item);
766
+ pending.push({ value: item, leave: true });
767
+ if (Array.isArray(item)) {
768
+ for (const child of item)
769
+ pending.push({ value: child });
770
+ }
771
+ else {
772
+ for (const [key, child] of Object.entries(item)) {
773
+ if (inspect(key))
774
+ return true;
775
+ pending.push({ value: child });
776
+ }
777
+ }
778
+ }
779
+ }
780
+ return false;
781
+ }
782
+ /** Refuse a withheld destination copied into customer data, before any dispatch. */
783
+ export function refuseWithheldLinks(value, title) {
784
+ if (!carriesWithheldLink(value))
785
+ return;
786
+ throw new ConnectorCallError("invalid_args", `This write carries a URL whose query or fragment Connecta withheld ([redacted]), so ${title} would store the placeholder. Do not write withheld URLs back; obtain the original link or omit the field.`);
787
+ }
788
+ /** Upload admission inspects actual bytes, including base64-decoded files and multipart blobs. */
789
+ export async function refuseWithheldUpload(body, title) {
790
+ if (body instanceof FormData) {
791
+ for (const [name, part] of body) {
792
+ refuseWithheldLinks(name, title);
793
+ if (typeof part === "string")
794
+ refuseWithheldLinks(part, title);
795
+ else {
796
+ refuseWithheldLinks(part.name, title);
797
+ refuseWithheldLinks(await part.text(), title);
798
+ }
799
+ }
800
+ }
801
+ else if (body instanceof Blob)
802
+ refuseWithheldLinks(await body.text(), title);
803
+ else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
804
+ refuseWithheldLinks(new TextDecoder().decode(body), title);
805
+ }
806
+ else
807
+ refuseWithheldLinks(body instanceof URLSearchParams ? body.toString() : body, title);
808
+ }
809
+ /** Only for documented JSON fields the vendor decodes as base64, never arbitrary strings. */
810
+ export async function refuseWithheldBase64(value, title, maxDecodedBytes) {
811
+ if (typeof value !== "string") {
812
+ throw new ConnectorCallError("invalid_args", "Not sent: a base64 field must be a string.");
813
+ }
814
+ // One decode, bounded by this field's own encoded length (at most 3/4 as
815
+ // many bytes), itself bounded by the host's request limit. Operation-specific
816
+ // value limits are independent of response caps and raw-upload caps.
817
+ let decoded;
818
+ try {
819
+ decoded = atob(value);
820
+ }
821
+ catch {
822
+ throw new ConnectorCallError("invalid_args", "Not sent: a base64 field must be valid base64.");
823
+ }
824
+ if (maxDecodedBytes !== undefined && decoded.length > maxDecodedBytes) {
825
+ throw new ConnectorCallError("invalid_args", `Not sent: a base64 field must encode at most ${maxDecodedBytes} bytes.`);
826
+ }
827
+ const bytes = new Uint8Array(decoded.length);
828
+ for (let i = 0; i < decoded.length; i++)
829
+ bytes[i] = decoded.charCodeAt(i);
830
+ await refuseWithheldUpload(bytes, title);
831
+ }
190
832
  /**
191
833
  * A destination URL reduced to its origin: webhook, drain, and bearer URLs
192
834
  * can carry a secret in the path itself (`hooks.slack.com/services/…`), so
@@ -298,10 +940,12 @@ export function validPath(raw) {
298
940
  return segments.length > 0 && segments.every((segment) => SEGMENT.test(segment));
299
941
  }
300
942
  /** Apply one reviewed path to a body in place of its value. */
301
- function applyPath(body, raw) {
943
+ function applyPath(body, raw, transform) {
302
944
  const { mode, segments } = parsePath(raw);
303
945
  const step = (current, index) => {
304
946
  if (index === segments.length) {
947
+ if (transform)
948
+ return transform(current);
305
949
  if (current === undefined || current === null)
306
950
  return current;
307
951
  if (mode === "url")
@@ -450,8 +1094,16 @@ function heuristic(value, exempt) {
450
1094
  const walk = (current, trail, parent, depth) => {
451
1095
  if (depth > MAX_DEPTH)
452
1096
  return REDACTED;
453
- if (typeof current === "string")
454
- return URL_SHAPE.test(current) ? sanitizeUrl(current) : current;
1097
+ if (typeof current === "string") {
1098
+ // Scan original escaped text before URL parsing. A string containing
1099
+ // HTML, Markdown or escapes is text, even when it has no whitespace.
1100
+ // Reviewed vendors already scanned signing names and selected text;
1101
+ // generic names in customer data must retain their original bytes.
1102
+ if (!exempt.reviewedUrls)
1103
+ return URL_SHAPE.test(current) ? sanitizeUrl(current) : current;
1104
+ const whole = URL_SHAPE.test(current) && !/[\s<>"'`\\]|&(?:#|[a-z]+;)|\]\(/i.test(current);
1105
+ return whole ? sanitizeUrl(current, false) : current;
1106
+ }
455
1107
  if (Array.isArray(current))
456
1108
  return current.map((item) => walk(item, trail, parent, depth + 1));
457
1109
  if (!isRecord(current))
@@ -548,6 +1200,40 @@ export function valueSafety(table, index) {
548
1200
  redact(data, method, template) {
549
1201
  const reviewed = verdict(method, template);
550
1202
  let out = data;
1203
+ if (table.embeddedUrls) {
1204
+ // Do this before any reviewed URL rule parses/serializes the text.
1205
+ out = mapStrings(out, withholdSigningParams, 0);
1206
+ for (const path of table.embeddedUrls.operations?.[`${method} ${template}`] ?? []) {
1207
+ out = applyPath(out, path, withholdEmbeddedUrlsIn);
1208
+ }
1209
+ const resources = table.embeddedUrls.resources;
1210
+ if (resources) {
1211
+ const scan = (value, depth) => {
1212
+ if (depth > MAX_DEPTH || value === null || typeof value !== "object")
1213
+ return value;
1214
+ if (Array.isArray(value))
1215
+ return value.map((item) => scan(item, depth + 1));
1216
+ let clean = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scan(item, depth + 1)]));
1217
+ const inner = clean[resources.key];
1218
+ const innerType = isRecord(inner) ? inner[resources.key] : undefined;
1219
+ if (typeof innerType === "string" && Object.hasOwn(resources.paths, innerType)) {
1220
+ for (const partial of resources.partials ?? []) {
1221
+ if (!Object.hasOwn(clean, partial))
1222
+ continue;
1223
+ for (const path of resources.paths[innerType])
1224
+ clean[partial] = applyPath(clean[partial], path, withholdEmbeddedUrlsIn);
1225
+ }
1226
+ }
1227
+ const type = clean[resources.key];
1228
+ if (typeof type === "string" && Object.hasOwn(resources.paths, type)) {
1229
+ for (const path of resources.paths[type])
1230
+ clean = applyPath(clean, path, withholdEmbeddedUrlsIn);
1231
+ }
1232
+ return clean;
1233
+ };
1234
+ out = scan(out, 0);
1235
+ }
1236
+ }
551
1237
  if (reviewed?.verdict === "redact")
552
1238
  for (const path of reviewed.paths)
553
1239
  out = applyPath(out, path);
@@ -557,7 +1243,13 @@ export function valueSafety(table, index) {
557
1243
  if (table.resources)
558
1244
  out = resourcePass(out, table.resources, verbatim, 0);
559
1245
  const keep = reviewed && reviewed.verdict !== "refuse" ? (reviewed.keep ?? []) : [];
560
- return heuristic(out, { keep: keep.map(trailOf), verbatim, fields, scopeMaps });
1246
+ return heuristic(out, {
1247
+ keep: keep.map(trailOf),
1248
+ verbatim,
1249
+ fields,
1250
+ scopeMaps,
1251
+ reviewedUrls: table.embeddedUrls !== undefined,
1252
+ });
561
1253
  },
562
1254
  withholdsErrors(op) {
563
1255
  const reviewed = verdict(op.method, op.path);