@mondaydotcomorg/z2h-cli 0.32.2 → 0.33.1

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 (67) hide show
  1. package/dist/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
  2. package/dist/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
  3. package/dist/backend/client.d.ts +32 -2
  4. package/dist/backend/client.d.ts.map +1 -1
  5. package/dist/backend/client.js +8 -5
  6. package/dist/backend/format-sandbox-run.d.ts +4 -0
  7. package/dist/backend/format-sandbox-run.d.ts.map +1 -0
  8. package/dist/backend/format-sandbox-run.js +49 -0
  9. package/dist/commands/__tests__/backend-invoke.test.d.ts +2 -0
  10. package/dist/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
  11. package/dist/commands/__tests__/custom-integrations.test.d.ts +2 -0
  12. package/dist/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
  13. package/dist/commands/backend.d.ts +6 -1
  14. package/dist/commands/backend.d.ts.map +1 -1
  15. package/dist/commands/backend.js +35 -3
  16. package/dist/commands/custom-integrations.d.ts +3 -0
  17. package/dist/commands/custom-integrations.d.ts.map +1 -0
  18. package/dist/commands/custom-integrations.js +51 -0
  19. package/dist/commands/deploy.js +3 -0
  20. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
  21. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
  22. package/dist/esm/backend/client.d.ts +32 -2
  23. package/dist/esm/backend/client.d.ts.map +1 -1
  24. package/dist/esm/backend/client.mjs +8 -5
  25. package/dist/esm/backend/format-sandbox-run.d.ts +4 -0
  26. package/dist/esm/backend/format-sandbox-run.d.ts.map +1 -0
  27. package/dist/esm/backend/format-sandbox-run.mjs +47 -0
  28. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts +2 -0
  29. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
  30. package/dist/esm/commands/__tests__/custom-integrations.test.d.ts +2 -0
  31. package/dist/esm/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
  32. package/dist/esm/commands/backend.d.ts +6 -1
  33. package/dist/esm/commands/backend.d.ts.map +1 -1
  34. package/dist/esm/commands/backend.mjs +36 -4
  35. package/dist/esm/commands/custom-integrations.d.ts +3 -0
  36. package/dist/esm/commands/custom-integrations.d.ts.map +1 -0
  37. package/dist/esm/commands/custom-integrations.mjs +48 -0
  38. package/dist/esm/commands/deploy.mjs +3 -0
  39. package/dist/esm/index.mjs +18 -4
  40. package/dist/esm/util/app-name.mjs +1 -1
  41. package/dist/esm/util/broker/app.d.ts +4 -1
  42. package/dist/esm/util/broker/app.d.ts.map +1 -1
  43. package/dist/index.js +18 -4
  44. package/dist/util/app-name.js +1 -1
  45. package/dist/util/broker/app.d.ts +4 -1
  46. package/dist/util/broker/app.d.ts.map +1 -1
  47. package/handler-api/backend-runner/backend-runner.errors.ts +13 -0
  48. package/handler-api/backend-runner/backend-runner.types.ts +3 -0
  49. package/handler-api/backend-runner/channels/channel-map.ts +26 -6
  50. package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
  51. package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
  52. package/handler-api/backend-runner/channels/custom-integration-channel/custom-integration.channel.ts +243 -0
  53. package/handler-api/signatures.json +4 -0
  54. package/package.json +2 -2
  55. package/src/backend/__tests__/client.test.ts +17 -13
  56. package/src/backend/__tests__/format-sandbox-run.test.ts +42 -0
  57. package/src/backend/client.ts +27 -10
  58. package/src/backend/format-sandbox-run.ts +51 -0
  59. package/src/commands/__tests__/backend-invoke.test.ts +125 -0
  60. package/src/commands/__tests__/custom-integrations.test.ts +62 -0
  61. package/src/commands/backend.ts +43 -4
  62. package/src/commands/custom-integrations.ts +68 -0
  63. package/src/commands/deploy.ts +3 -0
  64. package/src/index.ts +24 -5
  65. package/src/util/app-name.ts +1 -1
  66. package/src/util/broker/__tests__/app.test.ts +32 -0
  67. package/src/util/broker/app.ts +4 -1
@@ -142,6 +142,9 @@ async function deployPreview({ paths, consumerName, appName, token, opts }) {
142
142
  entry,
143
143
  gitRemote: '',
144
144
  isPreview: true,
145
+ // The base app this preview is for — bigbrain-zth links the preview's row
146
+ // to it (once the caller's EDITOR access on it is confirmed) so custom
147
+ // integrations set on the base app are inherited by the preview.
145
148
  masterAppName: appName,
146
149
  });
147
150
  const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
@@ -9,6 +9,7 @@ import { cleanCommand } from './commands/clean.mjs';
9
9
  import { deployCommand } from './commands/deploy.mjs';
10
10
  import { deleteCommand } from './commands/delete.mjs';
11
11
  import { grantCommand, revokeCommand, transferOwnerCommand } from './commands/grant.mjs';
12
+ import { listCustomIntegrationsCommand, removeCustomIntegrationCommand } from './commands/custom-integrations.mjs';
12
13
  import { createWorkspaceCommand } from './commands/create-workspace.mjs';
13
14
  import { createCommand } from './commands/create.mjs';
14
15
  import { generateCommand } from './commands/generate.mjs';
@@ -164,6 +165,14 @@ async function main() {
164
165
  .command('transfer-owner [email]')
165
166
  .description('Transfer ownership of the current app to another monday.com user')
166
167
  .action(runCommand({}, (email) => transferOwnerCommand(email)));
168
+ program
169
+ .command('custom-integration:list')
170
+ .description("List the current app's registered custom integrations (never shows the PAT)")
171
+ .action(runCommand({}, () => listCustomIntegrationsCommand()));
172
+ program
173
+ .command('custom-integration:remove <name>')
174
+ .description('Remove a registered custom integration from the current app')
175
+ .action(runCommand({}, (name) => removeCustomIntegrationCommand(name)));
167
176
  program
168
177
  .command('delete')
169
178
  .description('Permanently delete the current app and take it offline (owners/admins only)')
@@ -171,15 +180,18 @@ async function main() {
171
180
  .action(runCommand({ data: cwdApp }, (opts) => deleteCommand(opts)));
172
181
  const backend = program
173
182
  .command('backend')
174
- .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
183
+ .description("Inspect the app's backend: validate handler sources or run a handler from the working tree");
175
184
  backend
176
185
  .command('validate')
177
186
  .description('Validate backend/handlers/*.js exactly as deploy would (syntax, isolate rules, integrations) without uploading')
178
187
  .action(runCommand({ data: cwdApp }, () => backendValidateCommand()));
179
188
  backend
180
189
  .command('invoke <handlerName>')
181
- .description('Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend')
182
- .option('--input <json>', 'JSON payload passed as ctx.input')
190
+ .description("Run backend/handlers/<handlerName>.js from the working tree — not the deployed handler — against the app's real " +
191
+ 'channels (real db data, real monday calls), and print a trace of every channel call and the ' +
192
+ 'result. Nothing is deployed. Exits 1 when the handler fails.')
193
+ .option('--input <json>', 'JSON passed to the handler as ctx.input (what the frontend would send)')
194
+ .option('--code <source>', 'run this source instead of backend/handlers/<handlerName>.js — <handlerName> is then just a label')
183
195
  .action(runCommand({
184
196
  data: ([handlerName]) => ({ app_name: getCwdAppName(), handler: handlerName }),
185
197
  }, (handlerName, opts) => backendInvokeCommand(handlerName, opts)));
@@ -206,7 +218,9 @@ void main()
206
218
  if (getOutputMode() === 'human') {
207
219
  selfUpdate();
208
220
  }
209
- process.exit(0);
221
+ // A command can finish normally and still report a non-zero code (e.g.
222
+ // `backend invoke` when the handler failed) via process.exitCode.
223
+ process.exit(process.exitCode ?? 0);
210
224
  })
211
225
  .catch(async (err) => {
212
226
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -27,7 +27,7 @@ function mfAppName(name) {
27
27
  // Names reserved by the host MF (mf-bigbrain-zth) for platform pages under
28
28
  // /bigbrain-vibe/<name>. Creating an app with one of these would shadow the
29
29
  // platform page in the URL, so we refuse upfront.
30
- const RESERVED_APP_NAMES = new Set(['generate-z2h-token']);
30
+ const RESERVED_APP_NAMES = new Set(['generate-z2h-token', 'set-custom-integration']);
31
31
  /**
32
32
  * Validates a de-scoped, kebab-case app name against every rule shared by
33
33
  * `create` and `deploy` (incl. `deploy --preview`) — pattern, reserved
@@ -30,7 +30,10 @@ export interface RegisterOrUpdateOptions {
30
30
  gitRemote: string;
31
31
  integrations?: string[];
32
32
  description?: string;
33
- /** For a preview app's first registration, the real app it previews. Ignored past v1. */
33
+ /**
34
+ * The base app a preview is for, sent only on first registration — the link
35
+ * is set once and is immutable thereafter, so a later update doesn't need it.
36
+ */
34
37
  masterAppName?: string;
35
38
  }
36
39
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AA6CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yFAAyF;IACzF,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BtF"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AA6CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BtF"}
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ const commands_clean = require('./commands/clean.js');
9
9
  const commands_deploy = require('./commands/deploy.js');
10
10
  const commands_delete = require('./commands/delete.js');
11
11
  const commands_grant = require('./commands/grant.js');
12
+ const commands_customIntegrations = require('./commands/custom-integrations.js');
12
13
  const commands_createWorkspace = require('./commands/create-workspace.js');
13
14
  const commands_create = require('./commands/create.js');
14
15
  const commands_generate = require('./commands/generate.js');
@@ -170,6 +171,14 @@ async function main() {
170
171
  .command('transfer-owner [email]')
171
172
  .description('Transfer ownership of the current app to another monday.com user')
172
173
  .action(util_runCommand.runCommand({}, (email) => commands_grant.transferOwnerCommand(email)));
174
+ program
175
+ .command('custom-integration:list')
176
+ .description("List the current app's registered custom integrations (never shows the PAT)")
177
+ .action(util_runCommand.runCommand({}, () => commands_customIntegrations.listCustomIntegrationsCommand()));
178
+ program
179
+ .command('custom-integration:remove <name>')
180
+ .description('Remove a registered custom integration from the current app')
181
+ .action(util_runCommand.runCommand({}, (name) => commands_customIntegrations.removeCustomIntegrationCommand(name)));
173
182
  program
174
183
  .command('delete')
175
184
  .description('Permanently delete the current app and take it offline (owners/admins only)')
@@ -177,15 +186,18 @@ async function main() {
177
186
  .action(util_runCommand.runCommand({ data: cwdApp }, (opts) => commands_delete.deleteCommand(opts)));
178
187
  const backend = program
179
188
  .command('backend')
180
- .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
189
+ .description("Inspect the app's backend: validate handler sources or run a handler from the working tree");
181
190
  backend
182
191
  .command('validate')
183
192
  .description('Validate backend/handlers/*.js exactly as deploy would (syntax, isolate rules, integrations) without uploading')
184
193
  .action(util_runCommand.runCommand({ data: cwdApp }, () => commands_backend.backendValidateCommand()));
185
194
  backend
186
195
  .command('invoke <handlerName>')
187
- .description('Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend')
188
- .option('--input <json>', 'JSON payload passed as ctx.input')
196
+ .description("Run backend/handlers/<handlerName>.js from the working tree — not the deployed handler — against the app's real " +
197
+ 'channels (real db data, real monday calls), and print a trace of every channel call and the ' +
198
+ 'result. Nothing is deployed. Exits 1 when the handler fails.')
199
+ .option('--input <json>', 'JSON passed to the handler as ctx.input (what the frontend would send)')
200
+ .option('--code <source>', 'run this source instead of backend/handlers/<handlerName>.js — <handlerName> is then just a label')
189
201
  .action(util_runCommand.runCommand({
190
202
  data: ([handlerName]) => ({ app_name: util_appName.getCwdAppName(), handler: handlerName }),
191
203
  }, (handlerName, opts) => commands_backend.backendInvokeCommand(handlerName, opts)));
@@ -212,7 +224,9 @@ void main()
212
224
  if (util_logger.getOutputMode() === 'human') {
213
225
  selfUpdate();
214
226
  }
215
- process.exit(0);
227
+ // A command can finish normally and still report a non-zero code (e.g.
228
+ // `backend invoke` when the handler failed) via process.exitCode.
229
+ process.exit(process.exitCode ?? 0);
216
230
  })
217
231
  .catch(async (err) => {
218
232
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -29,7 +29,7 @@ function mfAppName(name) {
29
29
  // Names reserved by the host MF (mf-bigbrain-zth) for platform pages under
30
30
  // /bigbrain-vibe/<name>. Creating an app with one of these would shadow the
31
31
  // platform page in the URL, so we refuse upfront.
32
- const RESERVED_APP_NAMES = new Set(['generate-z2h-token']);
32
+ const RESERVED_APP_NAMES = new Set(['generate-z2h-token', 'set-custom-integration']);
33
33
  /**
34
34
  * Validates a de-scoped, kebab-case app name against every rule shared by
35
35
  * `create` and `deploy` (incl. `deploy --preview`) — pattern, reserved
@@ -30,7 +30,10 @@ export interface RegisterOrUpdateOptions {
30
30
  gitRemote: string;
31
31
  integrations?: string[];
32
32
  description?: string;
33
- /** For a preview app's first registration, the real app it previews. Ignored past v1. */
33
+ /**
34
+ * The base app a preview is for, sent only on first registration — the link
35
+ * is set once and is immutable thereafter, so a later update doesn't need it.
36
+ */
34
37
  masterAppName?: string;
35
38
  }
36
39
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AA6CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yFAAyF;IACzF,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BtF"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AA6CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BtF"}
@@ -39,6 +39,7 @@ export class BackendNotFoundError extends BackendRunnerError {
39
39
  * traces, SQL text, or upstream error payloads.
40
40
  */
41
41
  export interface ChannelErrorDetails {
42
+ /** Which integration was at fault — an OAuth one (`monday`) or a custom PAT connection name (e.g. `stripe`); `code` tells you which. */
42
43
  integration?: string;
43
44
  }
44
45
 
@@ -53,3 +54,15 @@ export class ChannelError extends BackendRunnerError {
53
54
  this.name = 'ChannelError';
54
55
  }
55
56
  }
57
+
58
+ /**
59
+ * A sandbox run request the runner refuses before any code runs — the code is
60
+ * unusable or the app doesn't exist. Carries a `code` so
61
+ * the CLI can say exactly which, the same way a channel error does.
62
+ */
63
+ export class SandboxRequestError extends BackendRunnerError {
64
+ constructor(statusCode: number, message: string, public readonly code: string) {
65
+ super(statusCode, message);
66
+ this.name = 'SandboxRequestError';
67
+ }
68
+ }
@@ -34,6 +34,9 @@ export type ChannelHandler = (args: unknown[]) => Promise<unknown> | unknown;
34
34
  */
35
35
  export type ChannelMap = Record<string, ChannelHandler>;
36
36
 
37
+ /** Which path built the channels — a deployed handler (`invoke`) or source sent to the sandbox. Tags channel telemetry. */
38
+ export type RunMode = 'invoke' | 'sandbox';
39
+
37
40
  /**
38
41
  * A breakdown of where the isolate wall-clock went, in ms. Lets us see whether a
39
42
  * slow invoke is the V8 setup (create + compile) or the guest's own work + its
@@ -1,16 +1,24 @@
1
1
  import { logger } from '@mondaydotcomorg/trident-backend-runtime';
2
2
 
3
3
  import { withChannelTelemetry } from './channel-telemetry';
4
+ import { makeCustomIntegrationChannel } from './custom-integration-channel/custom-integration.channel';
4
5
  import { makeDbChannel } from './db-channel/db.channel';
5
6
  import { makeLlmChannel } from './llm-channel/llm.channel';
6
7
  import { makeMondayChannel } from './monday-channel/monday.channel';
7
8
  import { makeSnowflakeChannel } from './snowflake-channel/snowflake.channel';
8
- import type { Caller, ChannelMap } from '../backend-runner.types';
9
+ import type { Caller, ChannelMap, RunMode } from '../backend-runner.types';
10
+ import type { RunnerCustomIntegration } from '../runner-data.store';
9
11
 
10
12
  const OAUTH_CHANNELS: Record<string, (caller: Caller) => ChannelMap> = {
11
13
  monday: (caller) => makeMondayChannel(caller),
12
14
  };
13
15
 
16
+ /**
17
+ * Every integration with a registered channel. Sandbox runs build all of them —
18
+ * safe because each fetches the caller's token only when a method is called.
19
+ */
20
+ export const OAUTH_INTEGRATIONS = Object.keys(OAUTH_CHANNELS);
21
+
14
22
  /**
15
23
  * Builds the complete channel surface for one invocation — the only way out of
16
24
  * the isolate.
@@ -39,8 +47,11 @@ const OAUTH_CHANNELS: Record<string, (caller: Caller) => ChannelMap> = {
39
47
  *
40
48
  * ## Always-on vs declared
41
49
  *
42
- * - **Always on** — `db`, `snowflake`, `llm`. No user setup, no OAuth, nothing to
43
- * declare. Every app gets them.
50
+ * - **Always on** — `db`, `snowflake`, `llm`, `customIntegration`. No user setup,
51
+ * no OAuth, nothing to declare. Every app gets them. `customIntegration` is a
52
+ * generic PAT-authenticated HTTP proxy — the connection `name` a handler
53
+ * passes is a runtime dispatch argument, resolved against whatever the app
54
+ * registered for itself, not a manifest declaration.
44
55
  * - **Declared** — everything in {@link OAUTH_CHANNELS} (currently just `monday`).
45
56
  * These need a per-user OAuth token, so the app must list the integration in its
46
57
  * manifest *and* the running user must have connected it. Undeclared means the
@@ -58,14 +69,21 @@ const OAUTH_CHANNELS: Record<string, (caller: Caller) => ChannelMap> = {
58
69
  * setup) is wrong and retrying won't help; `502` means the upstream failed.
59
70
  *
60
71
  * Each key emits a unified call counter, duration distribution, and BI event tagged
61
- * by app, channel, method, and outcome. Uncaught failures also reach BI at the
72
+ * by app, mode (`invoke` or `sandbox`), channel, method, and outcome. Uncaught failures also reach BI at the
62
73
  * invocation level — see `runner.service.ts`.
63
74
  */
64
- export function buildChannels(appName: string, caller: Caller, declaredIntegrations: string[]): ChannelMap {
75
+ export function buildChannels(
76
+ appName: string,
77
+ caller: Caller,
78
+ declaredIntegrations: string[],
79
+ customIntegrations: RunnerCustomIntegration[],
80
+ mode: RunMode,
81
+ ): ChannelMap {
65
82
  const channels: ChannelMap = {
66
83
  ...makeDbChannel(appName),
67
84
  ...makeSnowflakeChannel(appName),
68
85
  ...makeLlmChannel(appName, caller),
86
+ ...makeCustomIntegrationChannel(appName, customIntegrations),
69
87
  };
70
88
 
71
89
  for (const integration of declaredIntegrations) {
@@ -80,5 +98,7 @@ export function buildChannels(appName: string, caller: Caller, declaredIntegrati
80
98
  }
81
99
  }
82
100
 
83
- return Object.fromEntries(Object.entries(channels).map(([key, fn]) => [key, withChannelTelemetry(appName, key, fn)]));
101
+ return Object.fromEntries(
102
+ Object.entries(channels).map(([key, fn]) => [key, withChannelTelemetry(appName, mode, key, fn)]),
103
+ );
84
104
  }
@@ -3,8 +3,8 @@ import { BackendRunnerEvents } from '@mondaydotcomorg/z2h-shared-utils/observabi
3
3
  import { trackEvent } from '@mondaydotcomorg/z2h-shared-utils/observability/trident';
4
4
 
5
5
  import { ChannelError } from '../backend-runner.errors';
6
- import type { ChannelHandler } from '../backend-runner.types';
7
6
  import { BI_SOURCE } from '../constants';
7
+ import type { ChannelHandler, RunMode } from '../backend-runner.types';
8
8
 
9
9
  type ChannelOutcome = 'success' | 'failure';
10
10
 
@@ -26,6 +26,7 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
26
26
 
27
27
  function recordChannelCall(
28
28
  appName: string,
29
+ mode: RunMode,
29
30
  channel: string,
30
31
  method: string,
31
32
  outcome: ChannelOutcome,
@@ -33,7 +34,7 @@ function recordChannelCall(
33
34
  error?: unknown,
34
35
  ): void {
35
36
  const durationMs = performance.now() - startedAt;
36
- const dimensions = { appName, channel, method };
37
+ const dimensions = { appName, mode, channel, method };
37
38
 
38
39
  metric.increment(BackendRunnerEvents.channel.callMetric, { ...dimensions, outcome });
39
40
  metric.distribution(BackendRunnerEvents.channel.durationMetric, durationMs, dimensions);
@@ -49,7 +50,12 @@ function recordChannelCall(
49
50
  );
50
51
  }
51
52
 
52
- export function withChannelTelemetry(appName: string, key: string, handler: ChannelHandler): ChannelHandler {
53
+ export function withChannelTelemetry(
54
+ appName: string,
55
+ mode: RunMode,
56
+ key: string,
57
+ handler: ChannelHandler,
58
+ ): ChannelHandler {
53
59
  const { channel, method } = parseChannelKey(key);
54
60
 
55
61
  return (args) => {
@@ -59,20 +65,20 @@ export function withChannelTelemetry(appName: string, key: string, handler: Chan
59
65
  if (isPromiseLike(result)) {
60
66
  return Promise.resolve(result).then(
61
67
  (value) => {
62
- recordChannelCall(appName, channel, method, 'success', startedAt);
68
+ recordChannelCall(appName, mode, channel, method, 'success', startedAt);
63
69
  return value;
64
70
  },
65
71
  (error: unknown) => {
66
- recordChannelCall(appName, channel, method, 'failure', startedAt, error);
72
+ recordChannelCall(appName, mode, channel, method, 'failure', startedAt, error);
67
73
  throw error;
68
74
  },
69
75
  );
70
76
  }
71
77
 
72
- recordChannelCall(appName, channel, method, 'success', startedAt);
78
+ recordChannelCall(appName, mode, channel, method, 'success', startedAt);
73
79
  return result;
74
80
  } catch (error) {
75
- recordChannelCall(appName, channel, method, 'failure', startedAt, error);
81
+ recordChannelCall(appName, mode, channel, method, 'failure', startedAt, error);
76
82
  throw error;
77
83
  }
78
84
  };
@@ -0,0 +1,52 @@
1
+ import { ChannelError } from '../backend-runner.errors';
2
+ import type { ChannelHandler, ChannelMap } from '../backend-runner.types';
3
+
4
+ /**
5
+ * One channel call as the sandbox reports it back to the author. Raw args are
6
+ * recorded — for `snowflake.query` that is the SQL text and its params, not the
7
+ * bound SQL the channel builds from them.
8
+ */
9
+ export interface ChannelCallTrace {
10
+ method: string;
11
+ args: unknown[];
12
+ durationMs: number;
13
+ ok: boolean;
14
+ /** Why the call failed — same shape as the run's `error`. A non-`ChannelError` is a 500 with no code. */
15
+ error?: { status: number; code?: string; message: string };
16
+ /** What the channel returned to the handler, as-is — e.g. Snowflake rows or monday's GraphQL `data`. */
17
+ result?: unknown;
18
+ }
19
+
20
+ type CallOutcome = Pick<ChannelCallTrace, 'ok' | 'error' | 'result'>;
21
+
22
+ function withChannelTrace(calls: ChannelCallTrace[], method: string, handler: ChannelHandler): ChannelHandler {
23
+ return async (args) => {
24
+ const startedAt = performance.now();
25
+ const record = (outcome: CallOutcome): void => {
26
+ calls.push({ method, args, durationMs: performance.now() - startedAt, ...outcome });
27
+ };
28
+
29
+ try {
30
+ const result = await handler(args);
31
+ record({ ok: true, result });
32
+ return result;
33
+ } catch (err) {
34
+ record({
35
+ ok: false,
36
+ error:
37
+ err instanceof ChannelError
38
+ ? { status: err.statusCode, code: err.code, message: err.message }
39
+ : { status: 500, message: err instanceof Error ? err.message : String(err) },
40
+ });
41
+ throw err;
42
+ }
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Wraps every channel so each call is appended to `calls` in the order it
48
+ * finished. Sandbox-only: the deployed invoke path never records a trace.
49
+ */
50
+ export function traceChannels(channels: ChannelMap, calls: ChannelCallTrace[]): ChannelMap {
51
+ return Object.fromEntries(Object.entries(channels).map(([key, fn]) => [key, withChannelTrace(calls, key, fn)]));
52
+ }
@@ -0,0 +1,243 @@
1
+ import { httpClient } from '@mondaydotcomorg/trident-backend-runtime';
2
+ import { decryptSecret } from '@mondaydotcomorg/z2h-shared-utils/secrets';
3
+
4
+ import { ChannelError } from '../../backend-runner.errors';
5
+ import { UPSTREAM_TIMEOUT_MS, withTimeout } from '../with-timeout';
6
+ import type { RunnerCustomIntegration } from '../../runner-data.store';
7
+ import type { ChannelMap } from '../../backend-runner.types';
8
+
9
+ const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
10
+ type AllowedMethod = (typeof ALLOWED_METHODS)[number];
11
+
12
+ interface CustomIntegrationRequestOptions {
13
+ url: string;
14
+ method?: string;
15
+ headers?: Record<string, string>;
16
+ body?: unknown;
17
+ query?: Record<string, string>;
18
+ }
19
+
20
+ function isRequestOptions(value: unknown): value is CustomIntegrationRequestOptions {
21
+ return !!value && typeof value === 'object' && typeof (value as { url?: unknown }).url === 'string';
22
+ }
23
+
24
+ /**
25
+ * The `customIntegration` channel — a generic, PAT-authenticated HTTP proxy for
26
+ * apps that connect to a third-party API by credential rather than OAuth (e.g.
27
+ * Stripe, an internal CRM). Distinct from `monday` (per-user OAuth token) and
28
+ * always-on like `db`/`snowflake`/`llm` — every app gets `ctx.api.v1.customIntegration`,
29
+ * and the connection `name` is a runtime dispatch argument rather than a
30
+ * manifest-declared channel.
31
+ *
32
+ * The handler supplies a URL, but only its path and query ever reach the
33
+ * upstream request — host and scheme are always the registered connection's:
34
+ * 1. Each named connection is pinned to one exact `allowedHost` at registration
35
+ * time. The actual request is always built as `https://${allowedHost}${path}`,
36
+ * so nothing in the handler-supplied URL — host, port, or scheme — can steer
37
+ * the org's credential to an unintended host or send it over plaintext http.
38
+ * 2. The actual request goes through `httpClient.untrustedExternal.fetchRaw`,
39
+ * never a raw fetch client — defense in depth against internal/private
40
+ * targets and cloud metadata endpoints (SSRF).
41
+ *
42
+ * The auth header is always set by the host, last, after any handler-supplied
43
+ * headers — a handler can never override it, and the decrypted PAT never
44
+ * crosses into the isolate.
45
+ */
46
+ export function makeCustomIntegrationChannel(appName: string, connections: RunnerCustomIntegration[]): ChannelMap {
47
+ return {
48
+ /**
49
+ * `ctx.api.v1.customIntegration.request(name, options)` — call a third-party
50
+ * API using a named, PAT-authenticated connection the app owner registered
51
+ * at `bigbrain.me/bigbrain-vibe/set-custom-integration`.
52
+ *
53
+ * The connection must already be registered — this channel does not create
54
+ * one, and there is no CLI command for it either: the PAT is entered only on
55
+ * that page, posted straight to the platform over the owner's own session,
56
+ * and never touches a terminal or an agent. The handler supplies the full
57
+ * request itself (URL, method, headers, body); the host looks up the named
58
+ * connection, decrypts its PAT, and injects it as the configured auth header
59
+ * (`Authorization: Bearer …` by default). The handler never sees the PAT,
60
+ * and cannot override the auth header even by supplying one with the same
61
+ * name.
62
+ *
63
+ * **Before registering, check the target API's real auth docs — do not
64
+ * assume `Authorization: Bearer`.** Many APIs use a different header (e.g.
65
+ * `x-api-key`, no scheme). Getting this wrong doesn't fail at registration —
66
+ * it fails later here, as an opaque `custom_integration_upstream_error` (502)
67
+ * wrapping a `401` from the upstream. Registration is browser-only — the PAT
68
+ * never touches a terminal or an agent — at
69
+ * `bigbrain.me/bigbrain-vibe/set-custom-integration`, where the host/header
70
+ * name/scheme are set (an empty scheme sends the raw token, no prefix).
71
+ * Before re-deriving the header/scheme from scratch, check what's already
72
+ * stored with `z2h-cli custom-integration:list` — always the base app; a
73
+ * preview has no connections of its own, see below.
74
+ *
75
+ * **Only the path and query of `options.url` are used** — the host and scheme
76
+ * are always the connection's registered `allowedHost`, over https, regardless
77
+ * of what you pass. Paste the full URL from the target API's docs for
78
+ * readability; the actual request always goes to the registered host. This is
79
+ * what keeps a credential scoped to the API it was meant for.
80
+ *
81
+ * **Previews:** a preview always uses its base app's connections, live,
82
+ * resolved fresh on every invoke — it can never register or remove its
83
+ * own. Registering (the page rejects a preview app name) or removing both
84
+ * reject with an error telling you to manage it on the base app instead.
85
+ * Change a connection there and every one of its previews picks it up
86
+ * immediately, no redeploy needed.
87
+ *
88
+ * @param name - The connection name it was registered under, e.g. `"stripe"`.
89
+ * @param options - `{ url, method?, headers?, body?, query? }`. Only `url`'s path and
90
+ * query are used — its host and scheme are ignored; the request always goes to the
91
+ * connection's registered host over https. `method` defaults to `"GET"` (one of GET,
92
+ * POST, PUT, PATCH, DELETE). `headers` are optional extra headers — the auth header is
93
+ * always set by the host afterward, so a header of that name here is ignored. `body` is
94
+ * forwarded as-is. `query` params are merged into the URL.
95
+ * @returns `{ status, headers, body }` — `body` is JSON-parsed when the response is JSON, otherwise the raw text. The response never includes the auth header, even if the upstream reflects it back.
96
+ *
97
+ * @throws `custom_integration_not_found` (404) — no connection with this name is registered for the app; register it at bigbrain.me/bigbrain-vibe/set-custom-integration first.
98
+ * @throws `custom_integration_invalid_argument` (400) — missing/malformed name, url, or method.
99
+ * @throws `custom_integration_upstream_error` (502) — the upstream request failed or returned a non-2xx status.
100
+ *
101
+ * @example
102
+ * // A Stripe connection registered as "stripe" / api.stripe.com at bigbrain.me/bigbrain-vibe/set-custom-integration
103
+ * const res = await ctx.api.v1.customIntegration.request('stripe', {
104
+ * url: 'https://api.stripe.com/v1/charges',
105
+ * method: 'GET',
106
+ * });
107
+ * return res.body;
108
+ *
109
+ * @example
110
+ * // POST with a JSON body and an extra (non-auth) header
111
+ * await ctx.api.v1.customIntegration.request('acme-crm', {
112
+ * url: 'https://api.acme-crm.example.com/v2/contacts',
113
+ * method: 'POST',
114
+ * headers: { 'Content-Type': 'application/json' },
115
+ * body: { name: ctx.input.name, email: ctx.input.email },
116
+ * });
117
+ */
118
+ 'customIntegration.request': async ([name, options]: unknown[]) => {
119
+ if (typeof name !== 'string' || !name) {
120
+ throw new ChannelError(
121
+ 400,
122
+ 'customIntegration.request requires a connection name as its first argument',
123
+ 'custom_integration_invalid_argument',
124
+ );
125
+ }
126
+
127
+ const connection = connections.find((c) => c.name === name);
128
+ if (!connection) {
129
+ throw new ChannelError(
130
+ 404,
131
+ `customIntegration: no connection named "${name}" is registered for app "${appName}"`,
132
+ 'custom_integration_not_found',
133
+ { integration: name },
134
+ );
135
+ }
136
+
137
+ if (!isRequestOptions(options)) {
138
+ throw new ChannelError(
139
+ 400,
140
+ 'customIntegration.request requires { url, method?, headers?, body?, query? } as its second argument',
141
+ 'custom_integration_invalid_argument',
142
+ { integration: name },
143
+ );
144
+ }
145
+
146
+ let requestedUrl: URL;
147
+ try {
148
+ requestedUrl = new URL(options.url);
149
+ } catch {
150
+ throw new ChannelError(
151
+ 400,
152
+ `customIntegration.request: "${options.url}" is not a valid URL`,
153
+ 'custom_integration_invalid_argument',
154
+ { integration: name },
155
+ );
156
+ }
157
+
158
+ // Only the path and query of the handler-supplied URL are used — host and
159
+ // scheme are always the registered connection's, over https, so no handler
160
+ // input can steer the PAT to an unintended host or send it over plaintext http.
161
+ // (Any fragment is dropped too, but that's moot — fragments never reach the
162
+ // wire in an HTTP request regardless of who builds the URL.)
163
+ const target = new URL(`https://${connection.allowedHost}${requestedUrl.pathname}${requestedUrl.search}`);
164
+
165
+ const method = (options.method ?? 'GET').toUpperCase();
166
+ if (!ALLOWED_METHODS.includes(method as AllowedMethod)) {
167
+ throw new ChannelError(
168
+ 400,
169
+ `customIntegration.request: unsupported method "${options.method}"`,
170
+ 'custom_integration_invalid_argument',
171
+ { integration: name },
172
+ );
173
+ }
174
+
175
+ let pat: string;
176
+ try {
177
+ pat = decryptSecret(connection.ciphertext);
178
+ } catch {
179
+ throw new ChannelError(
180
+ 500,
181
+ `customIntegration: connection "${name}" could not be decrypted`,
182
+ 'custom_integration_decrypt_failed',
183
+ { integration: name },
184
+ );
185
+ }
186
+
187
+ // Handler-supplied headers pass through, but the auth header is always
188
+ // set/overwritten host-side last — a handler can never inject, read, or
189
+ // override it, and it never crosses into the isolate.
190
+ const headers: Record<string, string> = { ...(options.headers ?? {}) };
191
+ const scheme = connection.authScheme ? `${connection.authScheme} ` : '';
192
+ headers[connection.authHeaderName] = `${scheme}${pat}`;
193
+
194
+ try {
195
+ const res = await withTimeout(
196
+ httpClient.untrustedExternal.fetchRaw({
197
+ url: target.toString(),
198
+ method: method as AllowedMethod,
199
+ headers,
200
+ body: options.body as never,
201
+ query: options.query,
202
+ }),
203
+ UPSTREAM_TIMEOUT_MS,
204
+ `customIntegration.request(${name})`,
205
+ );
206
+
207
+ const text = await res.text();
208
+ let body: unknown = text;
209
+ try {
210
+ body = text ? JSON.parse(text) : null;
211
+ } catch {
212
+ // Not JSON — return the raw text.
213
+ }
214
+
215
+ if (!res.ok) {
216
+ throw new ChannelError(
217
+ 502,
218
+ `customIntegration: upstream returned ${res.status}`,
219
+ 'custom_integration_upstream_error',
220
+ { integration: name },
221
+ );
222
+ }
223
+
224
+ // Strip the injected auth header from what crosses back to the handler —
225
+ // defense in depth in case the upstream ever reflects a request header
226
+ // back in its response (some APIs do this on error responses).
227
+ const responseHeaders = Object.fromEntries(res.headers.entries());
228
+ delete responseHeaders[connection.authHeaderName.toLowerCase()];
229
+ return { status: res.status, headers: responseHeaders, body };
230
+ } catch (err) {
231
+ if (err instanceof ChannelError) {
232
+ throw err;
233
+ }
234
+ throw new ChannelError(
235
+ 502,
236
+ `customIntegration: request failed: ${err instanceof Error ? err.message : String(err)}`,
237
+ 'custom_integration_upstream_error',
238
+ { integration: name },
239
+ );
240
+ }
241
+ },
242
+ } satisfies ChannelMap;
243
+ }