@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.
- package/dist/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
- package/dist/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
- package/dist/backend/client.d.ts +32 -2
- package/dist/backend/client.d.ts.map +1 -1
- package/dist/backend/client.js +8 -5
- package/dist/backend/format-sandbox-run.d.ts +4 -0
- package/dist/backend/format-sandbox-run.d.ts.map +1 -0
- package/dist/backend/format-sandbox-run.js +49 -0
- package/dist/commands/__tests__/backend-invoke.test.d.ts +2 -0
- package/dist/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
- package/dist/commands/__tests__/custom-integrations.test.d.ts +2 -0
- package/dist/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
- package/dist/commands/backend.d.ts +6 -1
- package/dist/commands/backend.d.ts.map +1 -1
- package/dist/commands/backend.js +35 -3
- package/dist/commands/custom-integrations.d.ts +3 -0
- package/dist/commands/custom-integrations.d.ts.map +1 -0
- package/dist/commands/custom-integrations.js +51 -0
- package/dist/commands/deploy.js +3 -0
- package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
- package/dist/esm/backend/client.d.ts +32 -2
- package/dist/esm/backend/client.d.ts.map +1 -1
- package/dist/esm/backend/client.mjs +8 -5
- package/dist/esm/backend/format-sandbox-run.d.ts +4 -0
- package/dist/esm/backend/format-sandbox-run.d.ts.map +1 -0
- package/dist/esm/backend/format-sandbox-run.mjs +47 -0
- package/dist/esm/commands/__tests__/backend-invoke.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
- package/dist/esm/commands/__tests__/custom-integrations.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
- package/dist/esm/commands/backend.d.ts +6 -1
- package/dist/esm/commands/backend.d.ts.map +1 -1
- package/dist/esm/commands/backend.mjs +36 -4
- package/dist/esm/commands/custom-integrations.d.ts +3 -0
- package/dist/esm/commands/custom-integrations.d.ts.map +1 -0
- package/dist/esm/commands/custom-integrations.mjs +48 -0
- package/dist/esm/commands/deploy.mjs +3 -0
- package/dist/esm/index.mjs +18 -4
- package/dist/esm/util/app-name.mjs +1 -1
- package/dist/esm/util/broker/app.d.ts +4 -1
- package/dist/esm/util/broker/app.d.ts.map +1 -1
- package/dist/index.js +18 -4
- package/dist/util/app-name.js +1 -1
- package/dist/util/broker/app.d.ts +4 -1
- package/dist/util/broker/app.d.ts.map +1 -1
- package/handler-api/backend-runner/backend-runner.errors.ts +13 -0
- package/handler-api/backend-runner/backend-runner.types.ts +3 -0
- package/handler-api/backend-runner/channels/channel-map.ts +26 -6
- package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
- package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
- package/handler-api/backend-runner/channels/custom-integration-channel/custom-integration.channel.ts +243 -0
- package/handler-api/signatures.json +4 -0
- package/package.json +2 -2
- package/src/backend/__tests__/client.test.ts +17 -13
- package/src/backend/__tests__/format-sandbox-run.test.ts +42 -0
- package/src/backend/client.ts +27 -10
- package/src/backend/format-sandbox-run.ts +51 -0
- package/src/commands/__tests__/backend-invoke.test.ts +125 -0
- package/src/commands/__tests__/custom-integrations.test.ts +62 -0
- package/src/commands/backend.ts +43 -4
- package/src/commands/custom-integrations.ts +68 -0
- package/src/commands/deploy.ts +3 -0
- package/src/index.ts +24 -5
- package/src/util/app-name.ts +1 -1
- package/src/util/broker/__tests__/app.test.ts +32 -0
- 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}`;
|
package/dist/esm/index.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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(
|
|
188
|
-
|
|
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
|
-
|
|
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);
|
package/dist/util/app-name.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/handler-api/backend-runner/channels/custom-integration-channel/custom-integration.channel.ts
ADDED
|
@@ -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
|
+
}
|