@mondaydotcomorg/z2h-cli 0.32.2 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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/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 +9 -0
- 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 +9 -0
- 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 +1 -0
- package/handler-api/backend-runner/channels/channel-map.ts +14 -3
- package/handler-api/backend-runner/channels/channel-telemetry.ts +1 -1
- 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/commands/__tests__/custom-integrations.test.ts +62 -0
- package/src/commands/custom-integrations.ts +68 -0
- package/src/commands/deploy.ts +3 -0
- package/src/index.ts +11 -0
- 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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"custom-integrations.test.d.ts","sourceRoot":"","sources":["../../../src/commands/__tests__/custom-integrations.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"custom-integrations.d.ts","sourceRoot":"","sources":["../../src/commands/custom-integrations.ts"],"names":[],"mappings":"AAyBA,wBAAsB,6BAA6B,IAAI,OAAO,CAAC,IAAI,CAAC,CAsBnE;AAED,wBAAsB,8BAA8B,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAkB5F"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
|
|
3
|
+
const util_logger = require('../util/logger.js');
|
|
4
|
+
const util_appName = require('../util/app-name.js');
|
|
5
|
+
const constants = require('../constants.js');
|
|
6
|
+
const util_auth_authStore = require('../util/auth/auth-store.js');
|
|
7
|
+
|
|
8
|
+
function getTargetAppName() {
|
|
9
|
+
const consumerName = util_appName.getCwdAppName();
|
|
10
|
+
if (!consumerName) {
|
|
11
|
+
throw new Error('Run this from inside a consumer app directory (the one with your package.json).');
|
|
12
|
+
}
|
|
13
|
+
return util_appName.mfAppName(consumerName);
|
|
14
|
+
}
|
|
15
|
+
async function listCustomIntegrationsCommand() {
|
|
16
|
+
const appName = getTargetAppName();
|
|
17
|
+
const { token } = await util_auth_authStore.readZthAuth();
|
|
18
|
+
const res = await fetch(`${constants.brokerBaseUrl()}/z2h-apps/${appName}/custom-integrations`, {
|
|
19
|
+
headers: { Authorization: `Bearer ${token}`, ...constants.routingHeaders() },
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const text = await res.text();
|
|
23
|
+
throw new Error(`Listing custom integrations failed (${res.status}): ${text.slice(0, 1024)}`);
|
|
24
|
+
}
|
|
25
|
+
const { connections } = (await res.json());
|
|
26
|
+
if (connections.length === 0) {
|
|
27
|
+
util_logger.output(`[z2h-cli] "${appName}" has no custom integrations registered`, { ok: true, appName, connections: [] });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const lines = connections.map(c => ` ${c.name} → ${c.allowedHost} (${c.authHeaderName})${c.inherited ? ' [inherited from base app]' : ''}`);
|
|
31
|
+
util_logger.output(`[z2h-cli] "${appName}" custom integrations:\n${lines.join('\n')}`, { ok: true, appName, connections });
|
|
32
|
+
}
|
|
33
|
+
async function removeCustomIntegrationCommand(name) {
|
|
34
|
+
if (!name) {
|
|
35
|
+
throw new Error('Specify the connection name to remove — e.g. z2h-cli custom-integration:remove stripe');
|
|
36
|
+
}
|
|
37
|
+
const appName = getTargetAppName();
|
|
38
|
+
const { token } = await util_auth_authStore.readZthAuth();
|
|
39
|
+
const res = await fetch(`${constants.brokerBaseUrl()}/z2h-apps/${appName}/custom-integrations/${encodeURIComponent(name)}`, {
|
|
40
|
+
method: 'DELETE',
|
|
41
|
+
headers: { Authorization: `Bearer ${token}`, ...constants.routingHeaders() },
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
throw new Error(`Removing custom integration failed (${res.status}): ${text.slice(0, 1024)}`);
|
|
46
|
+
}
|
|
47
|
+
util_logger.output(`[z2h-cli] custom integration "${name}" removed from "${appName}"`, { ok: true, appName, name });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
exports.listCustomIntegrationsCommand = listCustomIntegrationsCommand;
|
|
51
|
+
exports.removeCustomIntegrationCommand = removeCustomIntegrationCommand;
|
package/dist/commands/deploy.js
CHANGED
|
@@ -148,6 +148,9 @@ async function deployPreview({ paths, consumerName, appName, token, opts }) {
|
|
|
148
148
|
entry,
|
|
149
149
|
gitRemote: '',
|
|
150
150
|
isPreview: true,
|
|
151
|
+
// The base app this preview is for — bigbrain-zth links the preview's row
|
|
152
|
+
// to it (once the caller's EDITOR access on it is confirmed) so custom
|
|
153
|
+
// integrations set on the base app are inherited by the preview.
|
|
151
154
|
masterAppName: appName,
|
|
152
155
|
});
|
|
153
156
|
const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"custom-integrations.test.d.ts","sourceRoot":"","sources":["../../../../src/commands/__tests__/custom-integrations.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"custom-integrations.d.ts","sourceRoot":"","sources":["../../../src/commands/custom-integrations.ts"],"names":[],"mappings":"AAyBA,wBAAsB,6BAA6B,IAAI,OAAO,CAAC,IAAI,CAAC,CAsBnE;AAED,wBAAsB,8BAA8B,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAkB5F"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { output } from '../util/logger.mjs';
|
|
2
|
+
import { getCwdAppName, mfAppName } from '../util/app-name.mjs';
|
|
3
|
+
import { brokerBaseUrl, routingHeaders } from '../constants.mjs';
|
|
4
|
+
import { readZthAuth } from '../util/auth/auth-store.mjs';
|
|
5
|
+
|
|
6
|
+
function getTargetAppName() {
|
|
7
|
+
const consumerName = getCwdAppName();
|
|
8
|
+
if (!consumerName) {
|
|
9
|
+
throw new Error('Run this from inside a consumer app directory (the one with your package.json).');
|
|
10
|
+
}
|
|
11
|
+
return mfAppName(consumerName);
|
|
12
|
+
}
|
|
13
|
+
async function listCustomIntegrationsCommand() {
|
|
14
|
+
const appName = getTargetAppName();
|
|
15
|
+
const { token } = await readZthAuth();
|
|
16
|
+
const res = await fetch(`${brokerBaseUrl()}/z2h-apps/${appName}/custom-integrations`, {
|
|
17
|
+
headers: { Authorization: `Bearer ${token}`, ...routingHeaders() },
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const text = await res.text();
|
|
21
|
+
throw new Error(`Listing custom integrations failed (${res.status}): ${text.slice(0, 1024)}`);
|
|
22
|
+
}
|
|
23
|
+
const { connections } = (await res.json());
|
|
24
|
+
if (connections.length === 0) {
|
|
25
|
+
output(`[z2h-cli] "${appName}" has no custom integrations registered`, { ok: true, appName, connections: [] });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const lines = connections.map(c => ` ${c.name} → ${c.allowedHost} (${c.authHeaderName})${c.inherited ? ' [inherited from base app]' : ''}`);
|
|
29
|
+
output(`[z2h-cli] "${appName}" custom integrations:\n${lines.join('\n')}`, { ok: true, appName, connections });
|
|
30
|
+
}
|
|
31
|
+
async function removeCustomIntegrationCommand(name) {
|
|
32
|
+
if (!name) {
|
|
33
|
+
throw new Error('Specify the connection name to remove — e.g. z2h-cli custom-integration:remove stripe');
|
|
34
|
+
}
|
|
35
|
+
const appName = getTargetAppName();
|
|
36
|
+
const { token } = await readZthAuth();
|
|
37
|
+
const res = await fetch(`${brokerBaseUrl()}/z2h-apps/${appName}/custom-integrations/${encodeURIComponent(name)}`, {
|
|
38
|
+
method: 'DELETE',
|
|
39
|
+
headers: { Authorization: `Bearer ${token}`, ...routingHeaders() },
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
throw new Error(`Removing custom integration failed (${res.status}): ${text.slice(0, 1024)}`);
|
|
44
|
+
}
|
|
45
|
+
output(`[z2h-cli] custom integration "${name}" removed from "${appName}"`, { ok: true, appName, name });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { listCustomIntegrationsCommand, removeCustomIntegrationCommand };
|
|
@@ -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)')
|
|
@@ -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)')
|
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
|
|
|
@@ -1,11 +1,13 @@
|
|
|
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
9
|
import type { Caller, ChannelMap } 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),
|
|
@@ -39,8 +41,11 @@ const OAUTH_CHANNELS: Record<string, (caller: Caller) => ChannelMap> = {
|
|
|
39
41
|
*
|
|
40
42
|
* ## Always-on vs declared
|
|
41
43
|
*
|
|
42
|
-
* - **Always on** — `db`, `snowflake`, `llm`. No user setup,
|
|
43
|
-
* declare. Every app gets them.
|
|
44
|
+
* - **Always on** — `db`, `snowflake`, `llm`, `customIntegration`. No user setup,
|
|
45
|
+
* no OAuth, nothing to declare. Every app gets them. `customIntegration` is a
|
|
46
|
+
* generic PAT-authenticated HTTP proxy — the connection `name` a handler
|
|
47
|
+
* passes is a runtime dispatch argument, resolved against whatever the app
|
|
48
|
+
* registered for itself, not a manifest declaration.
|
|
44
49
|
* - **Declared** — everything in {@link OAUTH_CHANNELS} (currently just `monday`).
|
|
45
50
|
* These need a per-user OAuth token, so the app must list the integration in its
|
|
46
51
|
* manifest *and* the running user must have connected it. Undeclared means the
|
|
@@ -61,11 +66,17 @@ const OAUTH_CHANNELS: Record<string, (caller: Caller) => ChannelMap> = {
|
|
|
61
66
|
* by app, channel, method, and outcome. Uncaught failures also reach BI at the
|
|
62
67
|
* invocation level — see `runner.service.ts`.
|
|
63
68
|
*/
|
|
64
|
-
export function buildChannels(
|
|
69
|
+
export function buildChannels(
|
|
70
|
+
appName: string,
|
|
71
|
+
caller: Caller,
|
|
72
|
+
declaredIntegrations: string[],
|
|
73
|
+
customIntegrations: RunnerCustomIntegration[],
|
|
74
|
+
): ChannelMap {
|
|
65
75
|
const channels: ChannelMap = {
|
|
66
76
|
...makeDbChannel(appName),
|
|
67
77
|
...makeSnowflakeChannel(appName),
|
|
68
78
|
...makeLlmChannel(appName, caller),
|
|
79
|
+
...makeCustomIntegrationChannel(appName, customIntegrations),
|
|
69
80
|
};
|
|
70
81
|
|
|
71
82
|
for (const integration of declaredIntegrations) {
|
|
@@ -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 } from '../backend-runner.types';
|
|
8
8
|
|
|
9
9
|
type ChannelOutcome = 'success' | 'failure';
|
|
10
10
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mondaydotcomorg/z2h-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"bin": "./bin/z2h-cli.js",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@aws-sdk/client-s3": "^3.706.0",
|
|
54
54
|
"@mondaydotcomorg/z2h-dashboard-templates": "^0.4.0",
|
|
55
|
-
"@mondaydotcomorg/z2h-shared-utils": "^0.
|
|
55
|
+
"@mondaydotcomorg/z2h-shared-utils": "^0.7.0",
|
|
56
56
|
"acorn": "^8.14.0",
|
|
57
57
|
"acorn-walk": "^8.3.4",
|
|
58
58
|
"chokidar": "^3.6.0",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { mkdtemp, rm, remove, ensureDir, writeJson } from 'fs-extra';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { listCustomIntegrationsCommand, removeCustomIntegrationCommand } from '../custom-integrations';
|
|
7
|
+
import * as authStore from '../../util/auth/auth-store';
|
|
8
|
+
|
|
9
|
+
vi.spyOn(authStore, 'readZthAuth').mockResolvedValue({ token: 'jwt-stub' });
|
|
10
|
+
|
|
11
|
+
function mockFetchOnce(status = 200, body: unknown = {}) {
|
|
12
|
+
const fetchMock = vi
|
|
13
|
+
.fn()
|
|
14
|
+
.mockResolvedValue({ ok: status < 400, status, text: async () => '', json: async () => body });
|
|
15
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
16
|
+
return fetchMock;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('custom-integration commands — app-name resolution', () => {
|
|
20
|
+
let tmp: string;
|
|
21
|
+
let cwd: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-custom-integration-'));
|
|
25
|
+
cwd = process.cwd();
|
|
26
|
+
process.chdir(tmp);
|
|
27
|
+
tmp = process.cwd();
|
|
28
|
+
await writeJson(path.join(tmp, 'package.json'), { name: 'my-app' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
process.chdir(cwd);
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
await rm(tmp, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('throws a clear error instead of crashing when run outside a consumer app directory', async () => {
|
|
38
|
+
await remove(path.join(tmp, 'package.json'));
|
|
39
|
+
|
|
40
|
+
await expect(listCustomIntegrationsCommand()).rejects.toThrow(/consumer app directory/);
|
|
41
|
+
await expect(removeCustomIntegrationCommand('stripe')).rejects.toThrow(/consumer app directory/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('targets the base app', async () => {
|
|
45
|
+
const fetchMock = mockFetchOnce(200, { connections: [] });
|
|
46
|
+
|
|
47
|
+
await listCustomIntegrationsCommand();
|
|
48
|
+
|
|
49
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
50
|
+
expect(url).toContain('/z2h-apps/mf-my-app/custom-integrations');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('remove targets the base app', async () => {
|
|
54
|
+
await ensureDir(path.join(tmp, '.zth'));
|
|
55
|
+
const fetchMock = mockFetchOnce(204);
|
|
56
|
+
|
|
57
|
+
await removeCustomIntegrationCommand('stripe');
|
|
58
|
+
|
|
59
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
60
|
+
expect(url).toContain('/z2h-apps/mf-my-app/custom-integrations/stripe');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { output } from '../util/logger';
|
|
2
|
+
import { getCwdAppName, mfAppName } from '../util/app-name';
|
|
3
|
+
import { brokerBaseUrl, routingHeaders } from '../constants';
|
|
4
|
+
import { readZthAuth } from '../util/auth/auth-store';
|
|
5
|
+
|
|
6
|
+
function getTargetAppName(): string {
|
|
7
|
+
const consumerName = getCwdAppName();
|
|
8
|
+
if (!consumerName) {
|
|
9
|
+
throw new Error('Run this from inside a consumer app directory (the one with your package.json).');
|
|
10
|
+
}
|
|
11
|
+
return mfAppName(consumerName);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CustomIntegrationSummary {
|
|
15
|
+
name: string;
|
|
16
|
+
allowedHost: string;
|
|
17
|
+
authHeaderName: string;
|
|
18
|
+
authScheme: string;
|
|
19
|
+
createdBy: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
/** True when this is a preview showing an entry inherited from its base app, not its own. */
|
|
23
|
+
inherited?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function listCustomIntegrationsCommand(): Promise<void> {
|
|
27
|
+
const appName = getTargetAppName();
|
|
28
|
+
const { token } = await readZthAuth();
|
|
29
|
+
|
|
30
|
+
const res = await fetch(`${brokerBaseUrl()}/z2h-apps/${appName}/custom-integrations`, {
|
|
31
|
+
headers: { Authorization: `Bearer ${token}`, ...routingHeaders() },
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
throw new Error(`Listing custom integrations failed (${res.status}): ${text.slice(0, 1024)}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { connections } = (await res.json()) as { connections: CustomIntegrationSummary[] };
|
|
39
|
+
if (connections.length === 0) {
|
|
40
|
+
output(`[z2h-cli] "${appName}" has no custom integrations registered`, { ok: true, appName, connections: [] });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lines = connections.map(
|
|
45
|
+
c => ` ${c.name} → ${c.allowedHost} (${c.authHeaderName})${c.inherited ? ' [inherited from base app]' : ''}`
|
|
46
|
+
);
|
|
47
|
+
output(`[z2h-cli] "${appName}" custom integrations:\n${lines.join('\n')}`, { ok: true, appName, connections });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function removeCustomIntegrationCommand(name: string | undefined): Promise<void> {
|
|
51
|
+
if (!name) {
|
|
52
|
+
throw new Error('Specify the connection name to remove — e.g. z2h-cli custom-integration:remove stripe');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const appName = getTargetAppName();
|
|
56
|
+
const { token } = await readZthAuth();
|
|
57
|
+
|
|
58
|
+
const res = await fetch(`${brokerBaseUrl()}/z2h-apps/${appName}/custom-integrations/${encodeURIComponent(name)}`, {
|
|
59
|
+
method: 'DELETE',
|
|
60
|
+
headers: { Authorization: `Bearer ${token}`, ...routingHeaders() },
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const text = await res.text();
|
|
64
|
+
throw new Error(`Removing custom integration failed (${res.status}): ${text.slice(0, 1024)}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
output(`[z2h-cli] custom integration "${name}" removed from "${appName}"`, { ok: true, appName, name });
|
|
68
|
+
}
|
package/src/commands/deploy.ts
CHANGED
|
@@ -206,6 +206,9 @@ async function deployPreview({ paths, consumerName, appName, token, opts }: Depl
|
|
|
206
206
|
entry,
|
|
207
207
|
gitRemote: '',
|
|
208
208
|
isPreview: true,
|
|
209
|
+
// The base app this preview is for — bigbrain-zth links the preview's row
|
|
210
|
+
// to it (once the caller's EDITOR access on it is confirmed) so custom
|
|
211
|
+
// integrations set on the base app are inherited by the preview.
|
|
209
212
|
masterAppName: appName,
|
|
210
213
|
});
|
|
211
214
|
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { cleanCommand } from './commands/clean';
|
|
|
9
9
|
import { deployCommand } from './commands/deploy';
|
|
10
10
|
import { deleteCommand } from './commands/delete';
|
|
11
11
|
import { grantCommand, revokeCommand, transferOwnerCommand } from './commands/grant';
|
|
12
|
+
import { listCustomIntegrationsCommand, removeCustomIntegrationCommand } from './commands/custom-integrations';
|
|
12
13
|
import { createWorkspaceCommand } from './commands/create-workspace';
|
|
13
14
|
import { createCommand, CreateOptions } from './commands/create';
|
|
14
15
|
import { generateCommand, GenerateOptions } from './commands/generate';
|
|
@@ -260,6 +261,16 @@ async function main(): Promise<void> {
|
|
|
260
261
|
.description('Transfer ownership of the current app to another monday.com user')
|
|
261
262
|
.action(runCommand({}, (email: string | undefined) => transferOwnerCommand(email)));
|
|
262
263
|
|
|
264
|
+
program
|
|
265
|
+
.command('custom-integration:list')
|
|
266
|
+
.description("List the current app's registered custom integrations (never shows the PAT)")
|
|
267
|
+
.action(runCommand({}, () => listCustomIntegrationsCommand()));
|
|
268
|
+
|
|
269
|
+
program
|
|
270
|
+
.command('custom-integration:remove <name>')
|
|
271
|
+
.description('Remove a registered custom integration from the current app')
|
|
272
|
+
.action(runCommand({}, (name: string) => removeCustomIntegrationCommand(name)));
|
|
273
|
+
|
|
263
274
|
program
|
|
264
275
|
.command('delete')
|
|
265
276
|
.description('Permanently delete the current app and take it offline (owners/admins only)')
|
package/src/util/app-name.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function mfAppName(name: string): string {
|
|
|
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
|
/**
|
|
35
35
|
* Validates a de-scoped, kebab-case app name against every rule shared by
|
|
@@ -60,6 +60,38 @@ describe('registerOrUpdateApp', () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
it('forwards masterAppName on registration only', async () => {
|
|
64
|
+
const fetchMock = mockFetchOnce();
|
|
65
|
+
|
|
66
|
+
await registerOrUpdateApp({
|
|
67
|
+
appName: 'mf-zth-preview-abc-my-app',
|
|
68
|
+
token: 'jwt',
|
|
69
|
+
nextVersion: 1,
|
|
70
|
+
entry: ENTRY,
|
|
71
|
+
gitRemote: '',
|
|
72
|
+
masterAppName: 'mf-my-app',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
76
|
+
expect(JSON.parse(init.body as string)).toMatchObject({ masterAppName: 'mf-my-app' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not send masterAppName on update — the link is immutable after registration', async () => {
|
|
80
|
+
const fetchMock = mockFetchOnce();
|
|
81
|
+
|
|
82
|
+
await registerOrUpdateApp({
|
|
83
|
+
appName: 'mf-zth-preview-abc-my-app',
|
|
84
|
+
token: 'jwt',
|
|
85
|
+
nextVersion: 2,
|
|
86
|
+
entry: ENTRY,
|
|
87
|
+
gitRemote: '',
|
|
88
|
+
masterAppName: 'mf-my-app',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
92
|
+
expect(JSON.parse(init.body as string)).not.toHaveProperty('masterAppName');
|
|
93
|
+
});
|
|
94
|
+
|
|
63
95
|
it('updates (not registers) when nextVersion > 1, without prompting for visibility', async () => {
|
|
64
96
|
const fetchMock = mockFetchOnce();
|
|
65
97
|
|
package/src/util/broker/app.ts
CHANGED
|
@@ -115,7 +115,10 @@ export interface RegisterOrUpdateOptions {
|
|
|
115
115
|
gitRemote: string;
|
|
116
116
|
integrations?: string[];
|
|
117
117
|
description?: string;
|
|
118
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* The base app a preview is for, sent only on first registration — the link
|
|
120
|
+
* is set once and is immutable thereafter, so a later update doesn't need it.
|
|
121
|
+
*/
|
|
119
122
|
masterAppName?: string;
|
|
120
123
|
}
|
|
121
124
|
|