@zackbart/connecta 0.24.2 → 0.24.4

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 (97) hide show
  1. package/AGENTS.md +18 -20
  2. package/CHANGELOG.md +205 -1
  3. package/README.md +5 -6
  4. package/dist/auth/bearer.js +2 -0
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/branding.d.ts +31 -2
  8. package/dist/branding.js +116 -8
  9. package/dist/call-admission.d.ts +4 -0
  10. package/dist/call-admission.js +26 -0
  11. package/dist/catalog-drift.js +9 -4
  12. package/dist/catalog-service.d.ts +2 -0
  13. package/dist/catalog-service.js +25 -8
  14. package/dist/catalog.d.ts +2 -0
  15. package/dist/catalog.js +246 -121
  16. package/dist/connectors/api.d.ts +1 -1
  17. package/dist/connectors/api.js +21 -3
  18. package/dist/connectors/guarded-fetch.d.ts +6 -2
  19. package/dist/connectors/guarded-fetch.js +61 -24
  20. package/dist/connectors/remote-mcp.js +92 -57
  21. package/dist/errors.d.ts +28 -3
  22. package/dist/errors.js +60 -1
  23. package/dist/execute.js +85 -23
  24. package/dist/executor-result.js +3 -1
  25. package/dist/executors/quickjs-child.js +5 -1
  26. package/dist/executors/quickjs-protocol.d.ts +4 -0
  27. package/dist/executors/quickjs-runtime.d.ts +1 -1
  28. package/dist/executors/quickjs-runtime.js +38 -21
  29. package/dist/executors/quickjs.js +68 -27
  30. package/dist/index.d.ts +15 -1
  31. package/dist/index.js +36 -4
  32. package/dist/invocation.js +134 -93
  33. package/dist/mcp-result.js +3 -2
  34. package/dist/meta-tools.js +191 -36
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +38 -1
  37. package/dist/operator-ui/view.js +71 -0
  38. package/dist/providers/cloudflare.d.ts +14 -2
  39. package/dist/providers/cloudflare.js +107 -16
  40. package/dist/providers/linear.d.ts +26 -4
  41. package/dist/providers/linear.js +19 -4
  42. package/dist/providers/mixpanel.d.ts +16 -3
  43. package/dist/providers/mixpanel.js +13 -2
  44. package/dist/providers/notion.d.ts +8 -1
  45. package/dist/providers/notion.js +83 -10
  46. package/dist/providers/revenuecat.d.ts +30 -4
  47. package/dist/providers/revenuecat.js +42 -4
  48. package/dist/providers/stripe.d.ts +7 -1
  49. package/dist/providers/stripe.js +30 -4
  50. package/dist/providers/vercel.js +11 -1
  51. package/dist/registry.d.ts +22 -2
  52. package/dist/registry.js +101 -13
  53. package/dist/routes/mcp.d.ts +4 -1
  54. package/dist/routes/mcp.js +84 -13
  55. package/dist/routes/oauth.js +4 -0
  56. package/dist/routes/shared.d.ts +1 -0
  57. package/dist/routes/shared.js +4 -4
  58. package/dist/server.js +15 -3
  59. package/dist/skills.js +6 -5
  60. package/dist/storage/file.d.ts +6 -2
  61. package/dist/storage/file.js +312 -34
  62. package/dist/storage/memory.js +12 -1
  63. package/dist/types.d.ts +37 -0
  64. package/dist/ui.js +18 -10
  65. package/dist/validate.js +3 -3
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/documentation/architecture.md +193 -165
  69. package/documentation/auth.md +199 -145
  70. package/documentation/code-mode.md +433 -316
  71. package/documentation/meta-tools.md +363 -355
  72. package/examples/worker/AGENTS.md +2 -1
  73. package/examples/worker/README.md +12 -10
  74. package/examples/worker/src/index.ts +12 -15
  75. package/package.json +1 -2
  76. package/templates/node/.env.example +3 -3
  77. package/templates/node/AGENTS.md +5 -4
  78. package/templates/node/README.md +2 -1
  79. package/templates/node/package.json +1 -1
  80. package/templates/node/src/index.ts +23 -22
  81. package/documentation/call-admission.md +0 -142
  82. package/documentation/cloudflare.md +0 -471
  83. package/documentation/connector-guides.md +0 -176
  84. package/documentation/connectors.md +0 -389
  85. package/documentation/linear.md +0 -193
  86. package/documentation/mixpanel.md +0 -160
  87. package/documentation/notion.md +0 -308
  88. package/documentation/operations.md +0 -359
  89. package/documentation/operator-ui.md +0 -135
  90. package/documentation/optional-modules-upgrade.md +0 -243
  91. package/documentation/provider-conventions.md +0 -722
  92. package/documentation/request-admission.md +0 -170
  93. package/documentation/revenuecat.md +0 -305
  94. package/documentation/storage-and-credentials.md +0 -201
  95. package/documentation/stripe.md +0 -262
  96. package/documentation/upgrading.md +0 -754
  97. package/documentation/vercel.md +0 -241
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPolicyError, groupedSearchResult, MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, } from "./catalog-service.js";
3
3
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
4
- import { msg } from "./errors.js";
4
+ import { boundedEchoText, msg } from "./errors.js";
5
5
  import { serializeResultText } from "./executor-result.js";
6
6
  import { InvocationService, } from "./invocation.js";
7
7
  import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
@@ -37,7 +37,12 @@ async function discoveryResult(operation, hint) {
37
37
  try {
38
38
  const value = await operation();
39
39
  const text = boundedDiscoveryText(value, hint);
40
- return jsonResult(value, text);
40
+ const result = jsonResult(value, text);
41
+ const bytes = enc.encode(JSON.stringify(result)).length;
42
+ if (bytes > MAX_DISCOVERY_RESULT_BYTES) {
43
+ throw new DiscoveryPolicyError("result_too_large", `Discovery result is ${bytes} UTF-8 bytes, over the ${MAX_DISCOVERY_RESULT_BYTES}-byte ceiling. ${hint}`);
44
+ }
45
+ return result;
41
46
  }
42
47
  catch (err) {
43
48
  if (err instanceof DiscoveryPolicyError) {
@@ -94,29 +99,78 @@ export function alignEndToCharBoundary(bytes, offset, end, total) {
94
99
  }
95
100
  return e;
96
101
  }
97
- // --- result-size guard + get_result (feature 1) ---------------------------
102
+ /** Prefix of the chunked paging envelope; `v1`'s single inline key still reads. */
103
+ const RESULT_ENVELOPE_V2 = "connecta-result-v2:";
104
+ /** `<total bytes>:<bytes per chunk>:` follow the prefix, then chunk 0's base64. */
105
+ const RESULT_ENVELOPE_V2_HEADER = new RegExp(`^${RESULT_ENVELOPE_V2}(\\d+):(\\d+):`);
98
106
  /**
99
- * The one serialization every result guard measures, stashes, and pages: JSON
100
- * text for whatever JSON can represent, and `String(value)` for the returns
101
- * JSON renders as `undefined` — a handler that returns nothing, a function, or
102
- * a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
103
- * returning `undefined` for those, which is how a handler returning `undefined`
104
- * reached clients as a `{"type":"text"}` block carrying no `text` at all: the
105
- * size guard measured `enc.encode(undefined)` — the empty string, per the
106
- * WebIDL default and emitted the non-string unchanged (issue #42). `null`
107
- * needs no special case; JSON renders it as `"null"`.
108
- *
109
- * Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
110
- * the three give one answer to the same question. A value JSON cannot serialize
111
- * at all (a BigInt) still throws, as before, and is reported as a failure.
107
+ * Smallest chunk of result text stored under one key. A multiple of three so
108
+ * every chunk's base64 stands alone and a byte offset inside it lands on a
109
+ * whole quad, and a little under the 50,000-byte default page so a default page
110
+ * reads two or three chunks rather than dozens.
111
+ */
112
+ const RESULT_CHUNK_BYTES = 49_152;
113
+ /**
114
+ * Keys one stashed result may occupy. Chunking trades write count for read
115
+ * count, and both are real: every chunk is a storage write at stash time, and
116
+ * `fileStorage` rewrites its whole file per write. Above roughly 1.5 MB the
117
+ * chunks widen instead of multiplying, so a result costs a bounded number of
118
+ * writes and a page still reads a small fraction of it.
112
119
  */
120
+ const RESULT_MAX_CHUNKS = 32;
121
+ /** Chunk width for a result of `totalBytes`, always a multiple of three. */
122
+ function resultChunkBytes(totalBytes) {
123
+ return Math.max(RESULT_CHUNK_BYTES, Math.ceil(totalBytes / RESULT_MAX_CHUNKS / 3) * 3);
124
+ }
113
125
  /**
114
- * Stash `text` under `result:<uuid>` (ttl 900s) and describe it as the
115
- * truncation notice every over-cap path hands back.
126
+ * Base64 of `bytes`, in three-byte-aligned batches so the argument list of one
127
+ * spread never grows with the result. Alignment matters: an unaligned batch
128
+ * would pad mid-stream and the concatenation would no longer decode.
116
129
  */
117
- async function stashResult(text, results, totalBytes) {
130
+ function base64Of(bytes) {
131
+ let out = "";
132
+ for (let offset = 0; offset < bytes.length; offset += 12_288) {
133
+ out += btoa(String.fromCharCode(...bytes.subarray(offset, offset + 12_288)));
134
+ }
135
+ return out;
136
+ }
137
+ /** Stash a completed result, or return a notice without a paging route. */
138
+ async function stashResult(bytes, results) {
139
+ const totalBytes = bytes.length;
118
140
  const id = crypto.randomUUID();
119
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
141
+ try {
142
+ // Base64 permits byte-range decoding, and splitting the envelope across
143
+ // keys keeps a page's storage read proportional to the page instead of to
144
+ // the whole result (issue #540). Chunk 0 carries the header; the get_result
145
+ // reader below maps a byte offset back to chunk index and base64 quad.
146
+ const chunkBytes = resultChunkBytes(totalBytes);
147
+ const chunks = [`${RESULT_ENVELOPE_V2}${totalBytes}:${chunkBytes}:`];
148
+ for (let offset = 0; offset < bytes.length; offset += chunkBytes) {
149
+ const chunk = base64Of(bytes.subarray(offset, offset + chunkBytes));
150
+ if (offset === 0)
151
+ chunks[0] += chunk;
152
+ else
153
+ chunks.push(chunk);
154
+ }
155
+ if (!await results.set(`result:${id}`, chunks, RESULT_TTL_SECONDS)) {
156
+ throw new Error("Result stash capacity exhausted");
157
+ }
158
+ }
159
+ catch {
160
+ // Paging is advisory after a completed call, including an approved write.
161
+ // Neither backend prose nor a retry hint belongs in this successful result.
162
+ try {
163
+ results.warn();
164
+ }
165
+ catch {
166
+ // Logging cannot change the call either.
167
+ }
168
+ return {
169
+ truncated: true,
170
+ totalBytes,
171
+ hint: "Paging is unavailable. Use execute_code to reduce read-only results before returning them. Do not repeat a completed write to recover its result.",
172
+ };
173
+ }
120
174
  return {
121
175
  truncated: true,
122
176
  resultId: id,
@@ -141,7 +195,7 @@ async function guardEncoded(text, bytes, results, cap) {
141
195
  truncated: false,
142
196
  };
143
197
  }
144
- const notice = await stashResult(text, results, bytes.length);
198
+ const notice = await stashResult(bytes, results);
145
199
  const head = dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)));
146
200
  return {
147
201
  result: {
@@ -166,8 +220,12 @@ async function guardValue(value, results, cap) {
166
220
  const bytes = enc.encode(text);
167
221
  if (bytes.length <= cap)
168
222
  return { result: value, truncated: false };
223
+ const notice = await stashResult(bytes, results);
169
224
  return {
170
- result: await stashResult(text, results, bytes.length),
225
+ result: notice.resultId ? notice : {
226
+ ...notice,
227
+ preview: dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length))),
228
+ },
171
229
  truncated: true,
172
230
  };
173
231
  }
@@ -210,7 +268,7 @@ async function guardContent(content, results, cap) {
210
268
  if (content.every((b) => b.type === "text")) {
211
269
  return guardEncoded(text, bytes, results, cap);
212
270
  }
213
- const notice = await stashResult(text, results, bytes.length);
271
+ const notice = await stashResult(bytes, results);
214
272
  return {
215
273
  result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
216
274
  truncated: true,
@@ -259,7 +317,6 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
259
317
  const invocation = new InvocationService(registry, catalog, opts.activity);
260
318
  /** MCP adapter: shared invocation semantics plus MCP-only result shaping. */
261
319
  async function runCall(call, source, options = {}) {
262
- const results = registry.resultsStorage();
263
320
  const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
264
321
  const outcome = await invocation.invoke(call.address, call.args ?? {}, {
265
322
  source,
@@ -272,6 +329,13 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
272
329
  : {}),
273
330
  unwrapResult: call.resultMode === "value",
274
331
  processResult: async (result, resolved) => {
332
+ const results = {
333
+ set: (key, value, ttlSeconds) => registry.stashResult(key, value, ttlSeconds),
334
+ warn: () => registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger.warn("[connecta] result paging unavailable", {
335
+ connector: resolved.connector.id,
336
+ tool: resolved.toolName,
337
+ }),
338
+ };
275
339
  // Result-size cap for THIS call: the connector's own override wins,
276
340
  // then the deployment-wide value, then the built-in default (already
277
341
  // folded into `globalCap`). Resolved per call so one request can
@@ -292,7 +356,14 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
292
356
  }
293
357
  if (resolved.connector.kind === "mcp") {
294
358
  const mcpResult = result;
295
- const content = mcpResult?.content ?? [];
359
+ let content = mcpResult?.content ?? [];
360
+ if (!content.some((block) => block.type === "text") &&
361
+ mcpResult?.structuredContent !== undefined) {
362
+ content = [...content, {
363
+ type: "text",
364
+ text: JSON.stringify(mcpResult.structuredContent),
365
+ }];
366
+ }
296
367
  const guarded = await guardContent(content, results, cap);
297
368
  return processed(guarded.result, guarded.truncated);
298
369
  }
@@ -305,12 +376,15 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
305
376
  if (!outcome.ok) {
306
377
  const structuredRecovery = outcome.error.nextAction !== undefined;
307
378
  const recoveryRequired = structuredRecovery ||
379
+ // Sanitized `unavailable` diagnostics ride the structured shape too;
380
+ // the plain-text path would drop them (#539).
381
+ outcome.error.details !== undefined ||
308
382
  [
309
383
  "auth_required",
310
384
  "invalid_args",
311
385
  "input_required_unsupported",
312
386
  ].includes(outcome.error.code);
313
- const failedResult = recoveryRequired ||
387
+ const makeFailedResult = () => recoveryRequired ||
314
388
  call.resultMode === "value"
315
389
  ? jsonResult({
316
390
  ok: false,
@@ -320,6 +394,18 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
320
394
  ...(call.diagnostics ? { timing: outcome.timing } : {}),
321
395
  })
322
396
  : errorResult(outcome.error.message);
397
+ let failedResult = makeFailedResult();
398
+ // Value mode repeats the error in text and structuredContent. Account for
399
+ // both copies and JSON escaping when the bounded provider reason is large.
400
+ if (!recoveryRequired) {
401
+ const cap = resolveMaxResultBytes(outcome.resolved?.connector.maxResultBytes, globalCap);
402
+ let budget = 512;
403
+ while (enc.encode(JSON.stringify(failedResult)).length > cap && budget > 0) {
404
+ budget = Math.floor(budget / 2);
405
+ outcome.error.message = boundedEchoText(outcome.error.message, budget);
406
+ failedResult = makeFailedResult();
407
+ }
408
+ }
323
409
  if (recoveryRequired) {
324
410
  failedResult.isError = true;
325
411
  }
@@ -372,6 +458,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
372
458
  return { content: [{ type: "text", text: skill.content }] };
373
459
  },
374
460
  async searchTools(args) {
461
+ if (args.connector !== undefined && enc.encode(args.connector).length > 512) {
462
+ return discoveryErrorResult(new DiscoveryPolicyError("invalid_args", "connector must be at most 512 UTF-8 bytes."));
463
+ }
375
464
  return discoveryResult(async () => groupedSearchResult(await catalog.search({
376
465
  ...args,
377
466
  includeSchemaKeys: args.includeSchemas !== undefined,
@@ -401,29 +490,95 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
401
490
  `>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
402
491
  }
403
492
  const results = registry.resultsStorage();
404
- const stored = await results.get(`result:${args.id}`);
405
- if (stored === null || stored === undefined) {
406
- return errorResult(`Unknown or expired result id "${args.id}"`);
493
+ const unavailableResult = () => ({
494
+ ...jsonResult({
495
+ error: {
496
+ code: "unavailable",
497
+ message: "Result paging storage is unavailable.",
498
+ retryable: true,
499
+ },
500
+ }),
501
+ isError: true,
502
+ });
503
+ // `false` is a storage failure — retryable, and distinct from an id that
504
+ // is simply gone. Every key a page touches answers the same way, so a
505
+ // backend that dies halfway through a multi-chunk page says so.
506
+ const read = async (key) => {
507
+ try {
508
+ return await results.get(key) ?? null;
509
+ }
510
+ catch {
511
+ return false;
512
+ }
513
+ };
514
+ const stored = await read(`result:${args.id}`);
515
+ if (stored === false)
516
+ return unavailableResult();
517
+ if (stored === null) {
518
+ return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
519
+ }
520
+ const requestedOffset = args.offset ?? 0;
521
+ const maxBytes = args.maxBytes ?? globalCap;
522
+ // Read and decode only the chunks this page covers, plus a few bytes of
523
+ // UTF-8 boundary lookaround. Pre-upgrade entries stay readable for their
524
+ // short TTL: v1 inlined the whole envelope under one key, which is this
525
+ // format with a single chunk as wide as the result, and raw text before
526
+ // that still pays one full encode per page.
527
+ const chunked = RESULT_ENVELOPE_V2_HEADER.exec(stored.slice(0, 80));
528
+ const inline = chunked ? null : /^connecta-result-v1:(\d+):/.exec(stored.slice(0, 64));
529
+ const header = chunked ?? inline;
530
+ let bytes;
531
+ let total;
532
+ let start = 0;
533
+ if (header) {
534
+ total = Number(header[1]);
535
+ const chunkBytes = chunked ? Number(chunked[2]) : Math.max(total, 1);
536
+ start = Math.floor(Math.max(0, Math.min(requestedOffset, total) - 3) / 3) * 3;
537
+ const end = Math.min(total, requestedOffset + maxBytes + 4);
538
+ bytes = new Uint8Array(Math.max(0, end - start));
539
+ const lastChunk = Math.floor(Math.max(end - 1, start) / chunkBytes);
540
+ for (let index = Math.floor(start / chunkBytes); index <= lastChunk; index++) {
541
+ const encoded = index === 0
542
+ ? stored.slice(header[0].length)
543
+ : await read(`result:${args.id}#${index}`);
544
+ if (encoded === false)
545
+ return unavailableResult();
546
+ if (encoded === null) {
547
+ // A chunk expired or was evicted under its own header; the id can no
548
+ // longer serve this range, and inventing U+0000 filler would be worse.
549
+ return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
550
+ }
551
+ const chunkStart = index * chunkBytes;
552
+ // Both bounds are chunk-local. `from` inherits `start`'s three-byte
553
+ // alignment because every chunk boundary is a multiple of three.
554
+ const from = Math.max(start, chunkStart) - chunkStart;
555
+ const to = Math.min(end, chunkStart + chunkBytes, total) - chunkStart;
556
+ const binary = atob(encoded.slice(from / 3 * 4, Math.ceil(to / 3) * 4));
557
+ for (let at = from; at < to; at++) {
558
+ bytes[chunkStart + at - start] = binary.charCodeAt(at - from);
559
+ }
560
+ }
561
+ }
562
+ else {
563
+ bytes = enc.encode(stored);
564
+ total = bytes.length;
407
565
  }
408
- const bytes = enc.encode(stored);
409
- const total = bytes.length;
410
566
  // Validated above, so no coercion is needed here — only alignment. A
411
567
  // client that computes its own offsets can land inside a multi-byte
412
568
  // character, which would decode as U+FFFD; the offset actually served is
413
569
  // the boundary at or before it, and it is what the response reports back
414
570
  // as `offset` (issue #38).
415
- const offset = alignStartToCharBoundary(bytes, args.offset ?? 0);
571
+ const offset = start + alignStartToCharBoundary(bytes, requestedOffset - start);
416
572
  // Page size only: a stashed result carries no connector identity, so
417
573
  // get_result keeps the deployment-wide default when none is requested.
418
574
  // Both sides are validated by now — the argument above, `globalCap` at
419
575
  // intake — so `offset + maxBytes` always reaches past `offset`.
420
- const maxBytes = args.maxBytes ?? globalCap;
421
576
  // Align the slice end to a codepoint boundary so a multi-byte char is
422
577
  // never split across pages (which would emit U+FFFD on both sides).
423
578
  // `nextOffset` is this aligned end, so it is a valid boundary for the
424
579
  // next call and paging reassembles the original byte-for-byte.
425
- const end = alignEndToCharBoundary(bytes, offset, offset + maxBytes, total);
426
- const slice = dec.decode(bytes.slice(offset, end));
580
+ const end = start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start);
581
+ const slice = dec.decode(bytes.subarray(offset - start, end - start));
427
582
  const nextOffset = end < total ? end : undefined;
428
583
  return jsonResult({
429
584
  offset,
@@ -435,7 +590,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
435
590
  async authorizeConnector(args) {
436
591
  const connector = registry.getConnector(args.connector);
437
592
  if (!connector) {
438
- return errorResult(`Unknown connector "${args.connector}"`);
593
+ return errorResult(`Unknown connector "${boundedEchoText(args.connector)}"`);
439
594
  }
440
595
  if (!connector.startAuth) {
441
596
  if (!connector.credential) {