@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.
Files changed (33) hide show
  1. package/dist/commands/__tests__/custom-integrations.test.d.ts +2 -0
  2. package/dist/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
  3. package/dist/commands/custom-integrations.d.ts +3 -0
  4. package/dist/commands/custom-integrations.d.ts.map +1 -0
  5. package/dist/commands/custom-integrations.js +51 -0
  6. package/dist/commands/deploy.js +3 -0
  7. package/dist/esm/commands/__tests__/custom-integrations.test.d.ts +2 -0
  8. package/dist/esm/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
  9. package/dist/esm/commands/custom-integrations.d.ts +3 -0
  10. package/dist/esm/commands/custom-integrations.d.ts.map +1 -0
  11. package/dist/esm/commands/custom-integrations.mjs +48 -0
  12. package/dist/esm/commands/deploy.mjs +3 -0
  13. package/dist/esm/index.mjs +9 -0
  14. package/dist/esm/util/app-name.mjs +1 -1
  15. package/dist/esm/util/broker/app.d.ts +4 -1
  16. package/dist/esm/util/broker/app.d.ts.map +1 -1
  17. package/dist/index.js +9 -0
  18. package/dist/util/app-name.js +1 -1
  19. package/dist/util/broker/app.d.ts +4 -1
  20. package/dist/util/broker/app.d.ts.map +1 -1
  21. package/handler-api/backend-runner/backend-runner.errors.ts +1 -0
  22. package/handler-api/backend-runner/channels/channel-map.ts +14 -3
  23. package/handler-api/backend-runner/channels/channel-telemetry.ts +1 -1
  24. package/handler-api/backend-runner/channels/custom-integration-channel/custom-integration.channel.ts +243 -0
  25. package/handler-api/signatures.json +4 -0
  26. package/package.json +2 -2
  27. package/src/commands/__tests__/custom-integrations.test.ts +62 -0
  28. package/src/commands/custom-integrations.ts +68 -0
  29. package/src/commands/deploy.ts +3 -0
  30. package/src/index.ts +11 -0
  31. package/src/util/app-name.ts +1 -1
  32. package/src/util/broker/__tests__/app.test.ts +32 -0
  33. package/src/util/broker/app.ts +4 -1
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=custom-integrations.test.d.ts.map
@@ -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,3 @@
1
+ export declare function listCustomIntegrationsCommand(): Promise<void>;
2
+ export declare function removeCustomIntegrationCommand(name: string | undefined): Promise<void>;
3
+ //# sourceMappingURL=custom-integrations.d.ts.map
@@ -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;
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=custom-integrations.test.d.ts.map
@@ -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,3 @@
1
+ export declare function listCustomIntegrationsCommand(): Promise<void>;
2
+ export declare function removeCustomIntegrationCommand(name: string | undefined): Promise<void>;
3
+ //# sourceMappingURL=custom-integrations.d.ts.map
@@ -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}`;
@@ -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
- /** 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)')
@@ -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
 
@@ -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, no OAuth, nothing to
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(appName: string, caller: Caller, declaredIntegrations: string[]): ChannelMap {
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
 
@@ -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
+ }
@@ -1,4 +1,8 @@
1
1
  {
2
+ "customIntegration.request": [
3
+ "name",
4
+ "options"
5
+ ],
2
6
  "db.delete": [
3
7
  "key"
4
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.32.2",
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.6.2",
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
+ }
@@ -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)')
@@ -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
 
@@ -115,7 +115,10 @@ export interface RegisterOrUpdateOptions {
115
115
  gitRemote: string;
116
116
  integrations?: string[];
117
117
  description?: string;
118
- /** For a preview app's first registration, the real app it previews. Ignored past v1. */
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