@superblocksteam/shared 0.9615.4 → 0.9617.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 (183) hide show
  1. package/dist/database-lifecycle/appDatabaseImport.d.ts +12 -4
  2. package/dist/database-lifecycle/appDatabaseImport.d.ts.map +1 -1
  3. package/dist/database-lifecycle/appDatabaseImport.js +21 -3
  4. package/dist/database-lifecycle/appDatabaseImport.js.map +1 -1
  5. package/dist/database-lifecycle/appDatabaseImport.test.js +17 -0
  6. package/dist/database-lifecycle/appDatabaseImport.test.js.map +1 -1
  7. package/dist/database-lifecycle/index.d.ts +4 -2
  8. package/dist/database-lifecycle/index.d.ts.map +1 -1
  9. package/dist/database-lifecycle/index.js +6 -2
  10. package/dist/database-lifecycle/index.js.map +1 -1
  11. package/dist/database-lifecycle/index.test.js +1 -0
  12. package/dist/database-lifecycle/index.test.js.map +1 -1
  13. package/dist/plugins/templates/shared/auth.d.ts +17 -1
  14. package/dist/plugins/templates/shared/auth.d.ts.map +1 -1
  15. package/dist/plugins/templates/shared/auth.js +139 -2
  16. package/dist/plugins/templates/shared/auth.js.map +1 -1
  17. package/dist/plugins/templates/shared/auth.test.js +86 -0
  18. package/dist/plugins/templates/shared/auth.test.js.map +1 -1
  19. package/dist/plugins/templates/shared/openapi.d.ts +6 -1
  20. package/dist/plugins/templates/shared/openapi.d.ts.map +1 -1
  21. package/dist/plugins/templates/shared/openapi.js +7 -3
  22. package/dist/plugins/templates/shared/openapi.js.map +1 -1
  23. package/dist/plugins/templates/shared/restapiintegration.d.ts +1 -0
  24. package/dist/plugins/templates/shared/restapiintegration.d.ts.map +1 -1
  25. package/dist/plugins/templates/shared/restapiintegration.js +3 -1
  26. package/dist/plugins/templates/shared/restapiintegration.js.map +1 -1
  27. package/dist/plugins/templates/zendesk.d.ts +1 -0
  28. package/dist/plugins/templates/zendesk.d.ts.map +1 -1
  29. package/dist/plugins/templates/zendesk.js +41 -3
  30. package/dist/plugins/templates/zendesk.js.map +1 -1
  31. package/dist/plugins/templates/zendesk.test.d.ts +2 -0
  32. package/dist/plugins/templates/zendesk.test.d.ts.map +1 -0
  33. package/dist/plugins/templates/zendesk.test.js +79 -0
  34. package/dist/plugins/templates/zendesk.test.js.map +1 -0
  35. package/dist/socket/protocol.d.ts +23 -0
  36. package/dist/socket/protocol.d.ts.map +1 -1
  37. package/dist/socket/socketWithClientAuth.d.ts +3 -0
  38. package/dist/socket/socketWithClientAuth.d.ts.map +1 -1
  39. package/dist/socket/socketWithClientAuth.js +12 -1
  40. package/dist/socket/socketWithClientAuth.js.map +1 -1
  41. package/dist/socket/socketWithClientAuth.test.d.ts +2 -0
  42. package/dist/socket/socketWithClientAuth.test.d.ts.map +1 -0
  43. package/dist/socket/socketWithClientAuth.test.js +106 -0
  44. package/dist/socket/socketWithClientAuth.test.js.map +1 -0
  45. package/dist/types/datasource/auth.d.ts +2 -0
  46. package/dist/types/datasource/auth.d.ts.map +1 -1
  47. package/dist/types/datasource/auth.js.map +1 -1
  48. package/dist/types/datasource/index.d.ts +15 -0
  49. package/dist/types/datasource/index.d.ts.map +1 -1
  50. package/dist/types/datasource/index.js +31 -0
  51. package/dist/types/datasource/index.js.map +1 -1
  52. package/dist/types/datasource/index.test.js +20 -0
  53. package/dist/types/datasource/index.test.js.map +1 -1
  54. package/dist/types/deployment/index.d.ts +2 -0
  55. package/dist/types/deployment/index.d.ts.map +1 -1
  56. package/dist/types/event/index.d.ts +3 -0
  57. package/dist/types/event/index.d.ts.map +1 -1
  58. package/dist/types/event/index.js +3 -0
  59. package/dist/types/event/index.js.map +1 -1
  60. package/dist/types/observability/index.d.ts +1 -1
  61. package/dist/types/observability/index.d.ts.map +1 -1
  62. package/dist/types/observability/index.js.map +1 -1
  63. package/dist/types/plugin/form.d.ts +31 -0
  64. package/dist/types/plugin/form.d.ts.map +1 -1
  65. package/dist/types/plugin/form.js +39 -1
  66. package/dist/types/plugin/form.js.map +1 -1
  67. package/dist/types/plugin/form.test.js +24 -0
  68. package/dist/types/plugin/form.test.js.map +1 -1
  69. package/dist/types/plugin/integration.d.ts +32 -0
  70. package/dist/types/plugin/integration.d.ts.map +1 -1
  71. package/dist/types/plugin/integration.js +58 -1
  72. package/dist/types/plugin/integration.js.map +1 -1
  73. package/dist/types/plugin/integration.test.d.ts +2 -0
  74. package/dist/types/plugin/integration.test.d.ts.map +1 -0
  75. package/dist/types/plugin/integration.test.js +39 -0
  76. package/dist/types/plugin/integration.test.js.map +1 -0
  77. package/dist/types/publishPipeline/index.d.ts +2 -0
  78. package/dist/types/publishPipeline/index.d.ts.map +1 -1
  79. package/dist/types/publishPipeline/index.js.map +1 -1
  80. package/dist-esm/database-lifecycle/appDatabaseImport.d.ts +12 -4
  81. package/dist-esm/database-lifecycle/appDatabaseImport.d.ts.map +1 -1
  82. package/dist-esm/database-lifecycle/appDatabaseImport.js +21 -3
  83. package/dist-esm/database-lifecycle/appDatabaseImport.js.map +1 -1
  84. package/dist-esm/database-lifecycle/appDatabaseImport.test.js +17 -0
  85. package/dist-esm/database-lifecycle/appDatabaseImport.test.js.map +1 -1
  86. package/dist-esm/database-lifecycle/index.d.ts +4 -2
  87. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  88. package/dist-esm/database-lifecycle/index.js +5 -1
  89. package/dist-esm/database-lifecycle/index.js.map +1 -1
  90. package/dist-esm/database-lifecycle/index.test.js +1 -0
  91. package/dist-esm/database-lifecycle/index.test.js.map +1 -1
  92. package/dist-esm/plugins/templates/shared/auth.d.ts +17 -1
  93. package/dist-esm/plugins/templates/shared/auth.d.ts.map +1 -1
  94. package/dist-esm/plugins/templates/shared/auth.js +140 -3
  95. package/dist-esm/plugins/templates/shared/auth.js.map +1 -1
  96. package/dist-esm/plugins/templates/shared/auth.test.js +87 -1
  97. package/dist-esm/plugins/templates/shared/auth.test.js.map +1 -1
  98. package/dist-esm/plugins/templates/shared/openapi.d.ts +6 -1
  99. package/dist-esm/plugins/templates/shared/openapi.d.ts.map +1 -1
  100. package/dist-esm/plugins/templates/shared/openapi.js +7 -3
  101. package/dist-esm/plugins/templates/shared/openapi.js.map +1 -1
  102. package/dist-esm/plugins/templates/shared/restapiintegration.d.ts +1 -0
  103. package/dist-esm/plugins/templates/shared/restapiintegration.d.ts.map +1 -1
  104. package/dist-esm/plugins/templates/shared/restapiintegration.js +3 -1
  105. package/dist-esm/plugins/templates/shared/restapiintegration.js.map +1 -1
  106. package/dist-esm/plugins/templates/zendesk.d.ts +1 -0
  107. package/dist-esm/plugins/templates/zendesk.d.ts.map +1 -1
  108. package/dist-esm/plugins/templates/zendesk.js +42 -4
  109. package/dist-esm/plugins/templates/zendesk.js.map +1 -1
  110. package/dist-esm/plugins/templates/zendesk.test.d.ts +2 -0
  111. package/dist-esm/plugins/templates/zendesk.test.d.ts.map +1 -0
  112. package/dist-esm/plugins/templates/zendesk.test.js +77 -0
  113. package/dist-esm/plugins/templates/zendesk.test.js.map +1 -0
  114. package/dist-esm/socket/protocol.d.ts +23 -0
  115. package/dist-esm/socket/protocol.d.ts.map +1 -1
  116. package/dist-esm/socket/socketWithClientAuth.d.ts +3 -0
  117. package/dist-esm/socket/socketWithClientAuth.d.ts.map +1 -1
  118. package/dist-esm/socket/socketWithClientAuth.js +12 -1
  119. package/dist-esm/socket/socketWithClientAuth.js.map +1 -1
  120. package/dist-esm/socket/socketWithClientAuth.test.d.ts +2 -0
  121. package/dist-esm/socket/socketWithClientAuth.test.d.ts.map +1 -0
  122. package/dist-esm/socket/socketWithClientAuth.test.js +101 -0
  123. package/dist-esm/socket/socketWithClientAuth.test.js.map +1 -0
  124. package/dist-esm/types/datasource/auth.d.ts +2 -0
  125. package/dist-esm/types/datasource/auth.d.ts.map +1 -1
  126. package/dist-esm/types/datasource/auth.js.map +1 -1
  127. package/dist-esm/types/datasource/index.d.ts +15 -0
  128. package/dist-esm/types/datasource/index.d.ts.map +1 -1
  129. package/dist-esm/types/datasource/index.js +29 -0
  130. package/dist-esm/types/datasource/index.js.map +1 -1
  131. package/dist-esm/types/datasource/index.test.js +21 -1
  132. package/dist-esm/types/datasource/index.test.js.map +1 -1
  133. package/dist-esm/types/deployment/index.d.ts +2 -0
  134. package/dist-esm/types/deployment/index.d.ts.map +1 -1
  135. package/dist-esm/types/event/index.d.ts +3 -0
  136. package/dist-esm/types/event/index.d.ts.map +1 -1
  137. package/dist-esm/types/event/index.js +3 -0
  138. package/dist-esm/types/event/index.js.map +1 -1
  139. package/dist-esm/types/observability/index.d.ts +1 -1
  140. package/dist-esm/types/observability/index.d.ts.map +1 -1
  141. package/dist-esm/types/observability/index.js.map +1 -1
  142. package/dist-esm/types/plugin/form.d.ts +31 -0
  143. package/dist-esm/types/plugin/form.d.ts.map +1 -1
  144. package/dist-esm/types/plugin/form.js +36 -0
  145. package/dist-esm/types/plugin/form.js.map +1 -1
  146. package/dist-esm/types/plugin/form.test.js +25 -1
  147. package/dist-esm/types/plugin/form.test.js.map +1 -1
  148. package/dist-esm/types/plugin/integration.d.ts +32 -0
  149. package/dist-esm/types/plugin/integration.d.ts.map +1 -1
  150. package/dist-esm/types/plugin/integration.js +54 -0
  151. package/dist-esm/types/plugin/integration.js.map +1 -1
  152. package/dist-esm/types/plugin/integration.test.d.ts +2 -0
  153. package/dist-esm/types/plugin/integration.test.d.ts.map +1 -0
  154. package/dist-esm/types/plugin/integration.test.js +37 -0
  155. package/dist-esm/types/plugin/integration.test.js.map +1 -0
  156. package/dist-esm/types/publishPipeline/index.d.ts +2 -0
  157. package/dist-esm/types/publishPipeline/index.d.ts.map +1 -1
  158. package/dist-esm/types/publishPipeline/index.js.map +1 -1
  159. package/package.json +1 -1
  160. package/src/database-lifecycle/appDatabaseImport.test.ts +21 -0
  161. package/src/database-lifecycle/appDatabaseImport.ts +37 -4
  162. package/src/database-lifecycle/index.test.ts +1 -0
  163. package/src/database-lifecycle/index.ts +7 -1
  164. package/src/plugins/templates/shared/auth.test.ts +119 -1
  165. package/src/plugins/templates/shared/auth.ts +165 -2
  166. package/src/plugins/templates/shared/openapi.ts +15 -3
  167. package/src/plugins/templates/shared/restapiintegration.ts +3 -1
  168. package/src/plugins/templates/zendesk.test.ts +98 -0
  169. package/src/plugins/templates/zendesk.ts +44 -4
  170. package/src/socket/protocol.ts +19 -0
  171. package/src/socket/socketWithClientAuth.test.ts +117 -0
  172. package/src/socket/socketWithClientAuth.ts +15 -1
  173. package/src/types/datasource/auth.ts +4 -0
  174. package/src/types/datasource/index.test.ts +24 -1
  175. package/src/types/datasource/index.ts +31 -0
  176. package/src/types/deployment/index.ts +2 -0
  177. package/src/types/event/index.ts +3 -0
  178. package/src/types/observability/index.ts +2 -1
  179. package/src/types/plugin/form.test.ts +32 -1
  180. package/src/types/plugin/form.ts +54 -0
  181. package/src/types/plugin/integration.test.ts +45 -0
  182. package/src/types/plugin/integration.ts +62 -0
  183. package/src/types/publishPipeline/index.ts +2 -0
@@ -0,0 +1,117 @@
1
+ import { once } from 'node:events';
2
+
3
+ import { trace } from '@opentelemetry/api';
4
+ import WebSocket from 'isomorphic-ws';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import type { SocketMessage } from './socket.js';
8
+ import { ISocketWithClientAuth } from './socketWithClientAuth.js';
9
+ import { connectWebSocket } from './util.js';
10
+
11
+ describe('socket request authorization', () => {
12
+ const servers: WebSocket.Server[] = [];
13
+ const authorizations: Array<string | undefined> = [];
14
+
15
+ async function connect(options: { authorization?: string; getAuthorization?: () => Promise<string> }) {
16
+ const server = new WebSocket.Server({ host: '127.0.0.1', port: 0 });
17
+ servers.push(server);
18
+ server.on('connection', (ws) => {
19
+ ws.on('message', (data) => {
20
+ const message: SocketMessage = JSON.parse(data.toString());
21
+ if (!message.request) throw new Error('Expected a socket request');
22
+ authorizations.push(message.request.setAuthorization);
23
+ ws.send(JSON.stringify({ response: { error: null, id: message.request.id, payload: 'ok' } }));
24
+ });
25
+ });
26
+ await once(server, 'listening');
27
+ const address = server.address();
28
+ if (!address || typeof address === 'string') throw new Error('Expected a TCP test server');
29
+ const ws = await connectWebSocket(`ws://127.0.0.1:${address.port}`);
30
+ return new ISocketWithClientAuth(ws, options.authorization, {}, [], trace.getTracer('socket-auth-test'), {
31
+ getAuthorization: options.getAuthorization,
32
+ onClose: () => undefined
33
+ });
34
+ }
35
+
36
+ afterEach(async () => {
37
+ authorizations.length = 0;
38
+ for (const server of servers.splice(0)) {
39
+ for (const client of server.clients) client.terminate();
40
+ await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
41
+ }
42
+ });
43
+
44
+ it('refreshes authorization before each request and sends only changed credentials', async () => {
45
+ let authorization = 'Bearer first-token';
46
+ const socket = await connect({ authorization: 'Bearer expired-token', getAuthorization: async () => authorization });
47
+
48
+ await expect(socket.request('test.read', {})).resolves.toBe('ok');
49
+ await expect(socket.request('test.read', {})).resolves.toBe('ok');
50
+ authorization = 'Bearer second-token';
51
+ await expect(socket.request('test.read', {})).resolves.toBe('ok');
52
+
53
+ expect(authorizations).toEqual(['Bearer first-token', undefined, 'Bearer second-token']);
54
+ socket.close();
55
+ });
56
+
57
+ it('does not restore stale authorization when refreshes resolve out of order', async () => {
58
+ let resolveFirst!: (value: string) => void;
59
+ const first = new Promise<string>((resolve) => {
60
+ resolveFirst = resolve;
61
+ });
62
+ const getAuthorization = vi.fn().mockReturnValueOnce(first).mockResolvedValueOnce('Bearer new-token');
63
+ const socket = await connect({ getAuthorization });
64
+
65
+ const olderRequest = socket.request('test.read', {});
66
+ await expect(socket.request('test.read', {})).resolves.toBe('ok');
67
+ resolveFirst('Bearer old-token');
68
+ await expect(olderRequest).resolves.toBe('ok');
69
+
70
+ expect(authorizations).toEqual(['Bearer new-token', undefined]);
71
+ socket.close();
72
+ });
73
+
74
+ it('does not overwrite a manual account switch while refresh is pending', async () => {
75
+ let resolveRefresh!: (value: string) => void;
76
+ const refreshing = new Promise<string>((resolve) => {
77
+ resolveRefresh = resolve;
78
+ });
79
+ const socket = await connect({ getAuthorization: () => refreshing });
80
+
81
+ const pending = socket.request('test.read', {});
82
+ socket.setAuthorization('Bearer switched-token');
83
+ resolveRefresh('Bearer stale-token');
84
+ await expect(pending).resolves.toBe('ok');
85
+
86
+ expect(authorizations).toEqual(['Bearer switched-token']);
87
+ socket.close();
88
+ });
89
+
90
+ it('sends no request when refreshing authorization fails and allows a later retry', async () => {
91
+ let refreshFailed = true;
92
+ const socket = await connect({
93
+ authorization: 'Bearer expired-token',
94
+ getAuthorization: async () => {
95
+ if (refreshFailed) throw new Error('Refresh unavailable');
96
+ return 'Bearer refreshed-token';
97
+ }
98
+ });
99
+
100
+ await expect(socket.request('test.read', {})).rejects.toThrow('Refresh unavailable');
101
+ refreshFailed = false;
102
+ await expect(socket.request('test.read', {})).resolves.toBe('ok');
103
+
104
+ expect(authorizations).toEqual(['Bearer refreshed-token']);
105
+ socket.close();
106
+ });
107
+
108
+ it('preserves static authorization when no provider is supplied', async () => {
109
+ const socket = await connect({ authorization: 'Bearer personal-token' });
110
+
111
+ await expect(socket.request('test.read', {})).resolves.toBe('ok');
112
+ await expect(socket.request('test.read', {})).resolves.toBe('ok');
113
+
114
+ expect(authorizations).toEqual(['Bearer personal-token', undefined]);
115
+ socket.close();
116
+ });
117
+ });
@@ -15,6 +15,8 @@ export class ISocketWithClientAuth<ImplementedMethods, CallableMethods, RequestC
15
15
  > {
16
16
  private authorization?: string;
17
17
  private authorizationVersion = 0;
18
+ private authorizationRequestVersion = 0;
19
+ private readonly getAuthorization?: () => Promise<string>;
18
20
  private hasSentAuth = false;
19
21
 
20
22
  constructor(
@@ -28,15 +30,27 @@ export class ISocketWithClientAuth<ImplementedMethods, CallableMethods, RequestC
28
30
  timeouts?: SocketTimeouts;
29
31
  logger?: { error: (message?: string) => void; info: (message?: string) => void; warn: (message?: string) => void };
30
32
  getAbortSignal?: () => AbortSignal | undefined;
33
+ getAuthorization?: () => Promise<string>;
31
34
  }
32
35
  ) {
33
36
  super(ws, requestHandlers, globalMiddlewares, tracer, options);
34
37
  this.authorization = authorization;
38
+ this.getAuthorization = options.getAuthorization;
35
39
  }
36
40
 
37
41
  // override `request` from the base class to send `authorization` when appropriate
38
42
  async request<Params, Result>(method: string, params: Params): Promise<Result> {
39
- // only send `authorization` on the first request
43
+ if (this.getAuthorization) {
44
+ const requestVersion = ++this.authorizationRequestVersion;
45
+ const authorizationVersion = this.authorizationVersion;
46
+ const authorization = await this.getAuthorization();
47
+ if (
48
+ requestVersion === this.authorizationRequestVersion &&
49
+ authorizationVersion === this.authorizationVersion &&
50
+ authorization !== this.authorization
51
+ )
52
+ this.setAuthorization(authorization);
53
+ }
40
54
  const authorizationVersion = this.authorizationVersion;
41
55
  const authorization = this.hasSentAuth ? undefined : this.authorization;
42
56
  const result = await super.request<Params, Result>(method, params, authorization);
@@ -350,6 +350,7 @@ type PublicOAuthClientCredsConfig = {
350
350
  type OAuthClientCredsConfig = PublicOAuthClientCredsConfig & {
351
351
  clientSecret?: string;
352
352
  audience?: string;
353
+ clientAuthMethod?: ClientAuthMethod;
353
354
  };
354
355
 
355
356
  type PublicOAuthImplicitConfig = {
@@ -427,6 +428,9 @@ export enum TokenType {
427
428
 
428
429
  export type TokenMetadata = {
429
430
  email?: string;
431
+ // Host of the token endpoint the token was minted at. Recorded by every token store so a
432
+ // configuration that moves to another tenant can tell which rows belong to the previous one.
433
+ issuerHost?: string;
430
434
  };
431
435
 
432
436
  type FakeBoolean = boolean | string;
@@ -1,11 +1,13 @@
1
1
  import { describe, test, expect } from 'vitest';
2
2
 
3
+ import { SUPERBLOCKS_OPENAPI_TENANT_KEYWORD } from '../plugin/integration.js';
3
4
  import { IntegrationAuthType } from './auth.js';
4
5
  import {
5
6
  DatasourceConfiguration,
6
7
  isAuthenticatedDatasourceConfig,
7
8
  isAuthenticatedDatasourceConfigWithDynamicAuthType,
8
- getAuthTypeFromConfig
9
+ getAuthTypeFromConfig,
10
+ withResolvedTenantOAuthUrls
9
11
  } from './index.js';
10
12
 
11
13
  describe('isAuthenticatedDatasourceConfig', () => {
@@ -499,3 +501,24 @@ describe('getAuthTypeFromConfig', () => {
499
501
  expect(getAuthTypeFromConfig(config)).toBe(IntegrationAuthType.OAUTH2_CODE);
500
502
  });
501
503
  });
504
+
505
+ describe('withResolvedTenantOAuthUrls', () => {
506
+ test('resolves the OAuth URLs of a form-built configuration against the tenant it carries', () => {
507
+ // Test Connection sends live form values, which hold the template's placeholder; the payload
508
+ // that leaves the browser must not.
509
+ const configuration = {
510
+ authType: IntegrationAuthType.OAUTH2_CODE,
511
+ openApiTenantName: 'acme',
512
+ urlBase: `https://${SUPERBLOCKS_OPENAPI_TENANT_KEYWORD}.zendesk.com`,
513
+ authConfig: { clientId: 'abc', tokenUrl: `https://${SUPERBLOCKS_OPENAPI_TENANT_KEYWORD}.zendesk.com/oauth/tokens` }
514
+ } as unknown as Parameters<typeof withResolvedTenantOAuthUrls>[0];
515
+
516
+ expect(withResolvedTenantOAuthUrls(configuration)).toEqual({
517
+ ...configuration,
518
+ authConfig: { clientId: 'abc', tokenUrl: 'https://acme.zendesk.com/oauth/tokens' }
519
+ });
520
+ // urlBase is left to the executor, which has always substituted it itself.
521
+ const withoutTenant = { ...configuration, openApiTenantName: undefined };
522
+ expect(withResolvedTenantOAuthUrls(withoutTenant)).toBe(withoutTenant);
523
+ });
524
+ });
@@ -16,6 +16,7 @@ import { SecretsV1 as Secrets } from '@superblocksteam/types';
16
16
 
17
17
  import { Property } from '../common/property.js';
18
18
  import { Plugin, PluginMetadata } from '../plugin/index.js';
19
+ import { resolveAuthConfigTenantUrls } from '../plugin/integration.js';
19
20
  import { CreatorDto } from '../rbac/index.js';
20
21
  import { AWSAuthType, AuthConfig, AuthId, AuthType, IntegrationAuthType, PostgresAuthType, getAuthId } from './auth.js';
21
22
  export { PluginCommonV1 as PluginCommon } from '@superblocksteam/types';
@@ -276,6 +277,36 @@ export function getAuthTypeFromConfig(dc: DatasourceConfiguration): AuthType | u
276
277
  return undefined;
277
278
  }
278
279
 
280
+ /**
281
+ * The tenant a datasource is configured against, for the plugins that have one. Mirrors
282
+ * getAuthTypeFromConfig: the field only exists on REST-API-backed configurations, and callers
283
+ * hold the wider DatasourceConfiguration union.
284
+ */
285
+ export function getOpenApiTenantNameFromConfig(dc: DatasourceConfiguration): string | undefined {
286
+ // Configurations are stored as JSON, so the declared type is a claim about the column rather
287
+ // than a guarantee. Narrow at runtime so the signature is true and a non-string cannot reach
288
+ // the URL construction downstream.
289
+ const tenantName = 'openApiTenantName' in dc ? dc.openApiTenantName : undefined;
290
+ return typeof tenantName === 'string' ? tenantName : undefined;
291
+ }
292
+
293
+ /**
294
+ * A configuration with its per-tenant OAuth URLs resolved against the tenant it carries.
295
+ *
296
+ * The integration form keeps those URLs as the template's placeholder so Connect can follow the
297
+ * tenant as it is edited; the server resolves them on save. Anything else built from live form
298
+ * values and sent for execution, such as Test Connection, has to resolve them itself or the
299
+ * request carries an unusable host.
300
+ */
301
+ export function withResolvedTenantOAuthUrls<T extends DatasourceConfiguration>(dc: T): T {
302
+ const tenantName = getOpenApiTenantNameFromConfig(dc);
303
+ const authConfig = 'authConfig' in dc ? dc.authConfig : undefined;
304
+ if (!tenantName || !authConfig || typeof authConfig !== 'object') {
305
+ return dc;
306
+ }
307
+ return { ...dc, authConfig: resolveAuthConfigTenantUrls(authConfig as Record<string, unknown>, tenantName) };
308
+ }
309
+
279
310
  export type GraphQLDatasourceConfiguration = AuthenticatedHttpDatasourceConfig & {
280
311
  path?: string;
281
312
  };
@@ -21,4 +21,6 @@ export interface DeploymentDto {
21
21
  deployMessage: string;
22
22
  deploymentTime: number;
23
23
  deployedGitSha?: string | null;
24
+ /** True when this commit can publish again. Policy checks are off, or the stored checks already passed. */
25
+ policyChecksPassed?: boolean;
24
26
  }
@@ -8,6 +8,7 @@ export enum EventEntityType {
8
8
  WORKFLOW = 'workflow',
9
9
  SCHEDULED_JOB = 'scheduled_job',
10
10
  DATASOURCE = 'datasource',
11
+ FEEDBACK = 'feedback',
11
12
  FOLDER = 'folder',
12
13
  USER = 'user',
13
14
  ORGANIZATION = 'organization',
@@ -128,6 +129,8 @@ export enum EventAction {
128
129
  DISABLED = 'disabled',
129
130
  ENABLED = 'enabled',
130
131
  PUBLISH_ANYWAY = 'publish_anyway',
132
+ RATED = 'rated',
133
+ SHOWN = 'shown',
131
134
  EDIT_AFTER_CLARK = 'edit_after_clark'
132
135
  }
133
136
 
@@ -25,8 +25,9 @@ export type PostSinksResponseDto = {
25
25
  observability: Observability;
26
26
  };
27
27
 
28
+ // id, created and updated are server-owned; the create route rejects them with a 400.
28
29
  export type PostSinksRequestDto = {
29
- observability: Observability;
30
+ observability: Pick<Observability, 'type' | 'datasource' | 'config' | 'organizationId'>;
30
31
  };
31
32
 
32
33
  export type PutSinksResponseDto = {
@@ -6,9 +6,11 @@ import {
6
6
  assertPkcePrerequisites,
7
7
  assertSelectedDropdownOptionsSupported,
8
8
  getDisplayOnlyFieldNames,
9
- isDropdownOptionSupported
9
+ isDropdownOptionSupported,
10
+ withTenantOAuthUrlTemplates
10
11
  } from './form.js';
11
12
  import type { FormTemplate } from './form.js';
13
+ import { SUPERBLOCKS_OPENAPI_TENANT_KEYWORD } from './integration.js';
12
14
 
13
15
  describe('getDisplayOnlyFieldNames', () => {
14
16
  it('returns ALERT field names', () => {
@@ -332,3 +334,32 @@ describe('assertSelectedDropdownOptionsSupported', () => {
332
334
  expect(() => assertSelectedDropdownOptionsSupported({ authType: 'oauth', usePkce: true }, template, '0.0.16')).not.toThrow();
333
335
  });
334
336
  });
337
+
338
+ describe('withTenantOAuthUrlTemplates', () => {
339
+ it('replaces a stored, already-resolved OAuth URL with the template so Connect follows the tenant being edited', () => {
340
+ const tokenUrlTemplate = `https://${SUPERBLOCKS_OPENAPI_TENANT_KEYWORD}.zendesk.com/oauth/tokens`;
341
+ const template = {
342
+ sections: [
343
+ {
344
+ name: 'auth',
345
+ items: [
346
+ { name: 'authConfig.tokenUrl', componentType: FormComponentType.INPUT_TEXT, initialValue: tokenUrlTemplate },
347
+ { name: 'authConfig.clientId', componentType: FormComponentType.INPUT_TEXT, initialValue: '' }
348
+ ]
349
+ }
350
+ ]
351
+ } as unknown as FormTemplate;
352
+
353
+ const seeded = withTenantOAuthUrlTemplates(
354
+ { 'authConfig.tokenUrl': 'https://acme.zendesk.com/oauth/tokens', 'authConfig.clientId': 'stored-client' },
355
+ template
356
+ );
357
+
358
+ // Only the placeholder-bearing field is taken from the template; the rest keeps its stored
359
+ // value, and a template with fixed endpoints leaves everything as it was.
360
+ expect(seeded).toEqual({ 'authConfig.tokenUrl': tokenUrlTemplate, 'authConfig.clientId': 'stored-client' });
361
+ expect(withTenantOAuthUrlTemplates({ 'authConfig.tokenUrl': 'https://api.box.com/oauth2/token' }, undefined)).toEqual({
362
+ 'authConfig.tokenUrl': 'https://api.box.com/oauth2/token'
363
+ });
364
+ });
365
+ });
@@ -10,6 +10,7 @@ import {
10
10
  } from '../datasource/index.js';
11
11
  import { AgentType, compareSemVer } from '../organization/index.js';
12
12
  import { VERSION_INITIAL } from './index.js';
13
+ import { resolveOpenApiTenantUrl } from './integration.js';
13
14
 
14
15
  export interface FormTemplate {
15
16
  sections: FormSection[];
@@ -287,6 +288,14 @@ export enum EditorLanguage {
287
288
  export interface DropdownFormItem extends BaseFormItem {
288
289
  componentType: FormComponentType.DROPDOWN;
289
290
  options: DropdownOption[];
291
+ /**
292
+ * Rollout gate on the options themselves. While the LaunchDarkly flag evaluates to false only
293
+ * the options whose value is listed in `keep` are offered (plus any the form is told to
294
+ * preserve, such as a stored value), and the initial value falls back to the first of them.
295
+ * An allowlist rather than a denylist, so an option added later is hidden by construction
296
+ * until the flag is on.
297
+ */
298
+ optionsLdFlag?: { ldFlag: string; keep: string[] };
290
299
  showSearch?: boolean;
291
300
  optionFilterProp?: string;
292
301
  subtitle?: string;
@@ -349,6 +358,9 @@ type RevokeOAuthTokensPayload = {
349
358
  integrationId: string;
350
359
  configurationId: string;
351
360
  isNew: boolean;
361
+ // Set for providers that host each customer on their own subdomain; resolves the tenant
362
+ // placeholder the plugin template stores in its OAuth URLs.
363
+ openApiTenantName?: string;
352
364
  authType?: AuthType;
353
365
  authConfig?: {
354
366
  tokenScope: TokenScope;
@@ -364,6 +376,8 @@ type ConnectOAuthPayload = {
364
376
  configurationId: string;
365
377
  isNew: boolean;
366
378
  pluginId: string;
379
+ // See RevokeOAuthTokensPayload.
380
+ openApiTenantName?: string;
367
381
  accessType?: string;
368
382
  responseType?: string;
369
383
  stateConfigExclude?: AuthorizationStateConfig[];
@@ -577,3 +591,43 @@ export function assertSelectedDropdownOptionsSupported(
577
591
  }
578
592
  }
579
593
  }
594
+
595
+ // The OAuth URL fields a plugin template may declare with the tenant placeholder.
596
+ const TENANT_OAUTH_URL_FIELDS = ['tokenUrl', 'authorizationUrl', 'revokeTokenUrl'] as const;
597
+
598
+ export type TenantOAuthUrlField = (typeof TENANT_OAUTH_URL_FIELDS)[number];
599
+
600
+ /**
601
+ * The initial values of a datasource template's OAuth URL fields that carry the tenant
602
+ * placeholder, keyed by the `authConfig` field they belong to. Empty for a template whose
603
+ * provider has fixed endpoints.
604
+ */
605
+ export const tenantOAuthUrlTemplates = (template: FormTemplate | undefined): Map<TenantOAuthUrlField, string> => {
606
+ const templates = new Map<TenantOAuthUrlField, string>();
607
+ const items = template?.sections.flatMap((section) => section.items.flatMap(getRowItemsFromSectionItem)) ?? [];
608
+ for (const field of TENANT_OAUTH_URL_FIELDS) {
609
+ const item = items.find((candidate) => candidate.name === `authConfig.${field}`);
610
+ const value = item?.initialValue;
611
+ if (typeof value === 'string' && value !== resolveOpenApiTenantUrl(value, 'probe')) {
612
+ templates.set(field, value);
613
+ }
614
+ }
615
+ return templates;
616
+ };
617
+
618
+ /**
619
+ * Seed a form's values with the template's per-tenant OAuth URLs, over whatever the stored
620
+ * configuration holds for them.
621
+ *
622
+ * The stored configuration carries those URLs already resolved, because readers outside the
623
+ * browser use them verbatim. The form must not: Connect resolves the placeholder from the tenant
624
+ * as it is being edited, so a form seeded with the stored value would keep authorizing against
625
+ * the previous subdomain until the next save.
626
+ */
627
+ export const withTenantOAuthUrlTemplates = <T extends Record<string, unknown>>(values: T, template: FormTemplate | undefined): T => {
628
+ const seeded = { ...values };
629
+ for (const [field, url] of tenantOAuthUrlTemplates(template)) {
630
+ (seeded as Record<string, unknown>)[`authConfig.${field}`] = url;
631
+ }
632
+ return seeded;
633
+ };
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { resolveAuthConfigTenantUrls, resolveOpenApiTenantUrl, SUPERBLOCKS_OPENAPI_TENANT_KEYWORD } from './integration.js';
4
+
5
+ const TEMPLATE = `https://${SUPERBLOCKS_OPENAPI_TENANT_KEYWORD}.zendesk.com/oauth/tokens`;
6
+
7
+ describe('resolveOpenApiTenantUrl', () => {
8
+ it('refuses a tenant that would move the host out of the template', () => {
9
+ // The placeholder sits inside the host, so a tenant carrying a path, userinfo, query or
10
+ // fragment separator repoints the request -- `evil.com/` would aim it at evil.com. These
11
+ // URLs receive OAuth tokens and client secrets, so an unusable tenant is refused outright
12
+ // rather than escaped or trimmed.
13
+ for (const hostile of ['evil.com/', 'x@evil.com', 'evil.com#', 'evil.com?', 'evil.com:443', 'acme.zendesk.com']) {
14
+ expect(resolveOpenApiTenantUrl(TEMPLATE, hostile), hostile).toBe(TEMPLATE);
15
+ }
16
+ });
17
+
18
+ it('leaves the placeholder in place when no tenant is configured', () => {
19
+ // An unresolved placeholder is an unusable host, which is the point: the request fails
20
+ // instead of silently reaching whatever a stripped placeholder would resolve to.
21
+ expect(resolveOpenApiTenantUrl(TEMPLATE, undefined)).toBe(TEMPLATE);
22
+ });
23
+ });
24
+
25
+ describe('resolveAuthConfigTenantUrls', () => {
26
+ it('resolves the OAuth URLs a config carries, leaves other fields alone, and adds none it lacked', () => {
27
+ const resolved = resolveAuthConfigTenantUrls(
28
+ {
29
+ authorizationUrl: `https://${SUPERBLOCKS_OPENAPI_TENANT_KEYWORD}.zendesk.com/oauth/authorizations/new`,
30
+ tokenUrl: TEMPLATE,
31
+ clientId: 'client-id'
32
+ },
33
+ 'acme'
34
+ );
35
+
36
+ expect(resolved).toEqual({
37
+ authorizationUrl: 'https://acme.zendesk.com/oauth/authorizations/new',
38
+ tokenUrl: 'https://acme.zendesk.com/oauth/tokens',
39
+ clientId: 'client-id'
40
+ });
41
+ // toEqual ignores explicit undefined, so pin the absence separately: a key that appears from
42
+ // nowhere shows up in serialisation diffs and key counts downstream.
43
+ expect(resolved).not.toHaveProperty('revokeTokenUrl');
44
+ });
45
+ });
@@ -3,6 +3,68 @@ export const REST_API_BASE_PLUGIN_ID = 'restapi';
3
3
  const SALESFORCE_PLUGIN_ID = 'salesforce';
4
4
  export const SUPERBLOCKS_OPENAPI_TENANT_KEYWORD = '{SUPERBLOCKS_OPENAPI_TENANT_NAME}';
5
5
 
6
+ /**
7
+ * A single DNS label, which is all a tenant is allowed to be. The placeholder sits in the host
8
+ * of a URL the caller did not write, so a tenant carrying `/`, `@`, `#`, `?` or `:` would move
9
+ * the host somewhere else entirely -- `evil.com/` turns `https://<tenant>.zendesk.com/x` into a
10
+ * request to `evil.com`. Anything outside this alphabet is refused rather than sanitized.
11
+ */
12
+ const TENANT_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
13
+
14
+ /**
15
+ * Substitute the tenant placeholder in a plugin URL with the tenant a datasource was
16
+ * configured with.
17
+ *
18
+ * Some providers host every customer on their own subdomain, so a plugin template cannot
19
+ * carry a fixed URL. Those templates store the placeholder and callers resolve it at the
20
+ * point of use, the same way `urlBase` is resolved when a request is executed.
21
+ *
22
+ * A URL without the placeholder is returned unchanged. When the tenant is missing or is not a
23
+ * single DNS label the placeholder is deliberately left in place: the result is an unresolvable
24
+ * host that fails at request time, which is preferable to emitting a URL that points somewhere
25
+ * the plugin template never intended.
26
+ *
27
+ * `split`/`join` rather than `replace` is belt and braces: TENANT_LABEL already excludes `$`, so
28
+ * a tenant cannot reach here carrying `$&` or `$1` for `replace` to expand.
29
+ */
30
+ export const resolveOpenApiTenantUrl = (url: string | undefined, tenantName: string | undefined): string | undefined => {
31
+ if (!url || !url.includes(SUPERBLOCKS_OPENAPI_TENANT_KEYWORD)) {
32
+ return url;
33
+ }
34
+ if (typeof tenantName !== 'string' || !TENANT_LABEL.test(tenantName)) {
35
+ return url;
36
+ }
37
+ return url.split(SUPERBLOCKS_OPENAPI_TENANT_KEYWORD).join(tenantName);
38
+ };
39
+
40
+ /**
41
+ * Whether a URL still carries the tenant placeholder after resolution, i.e. the tenant was
42
+ * missing or refused. `new URL()` accepts the braces in the host, so callers that want to report
43
+ * this instead of navigating to an unresolvable host have to ask explicitly.
44
+ */
45
+ export const hasUnresolvedTenant = (url: string | undefined): boolean =>
46
+ typeof url === 'string' && url.includes(SUPERBLOCKS_OPENAPI_TENANT_KEYWORD);
47
+
48
+ /**
49
+ * Resolve every tenant-placeholder URL an OAuth config can carry, so callers do not each have to
50
+ * know which fields are affected. Fields that are absent or carry no placeholder are untouched.
51
+ */
52
+ export const resolveAuthConfigTenantUrls = <T extends { authorizationUrl?: string; tokenUrl?: string; revokeTokenUrl?: string }>(
53
+ authConfig: T,
54
+ tenantName: string | undefined
55
+ ): T => {
56
+ const resolved = { ...authConfig };
57
+ // Only rewrite fields the config actually carries; a field that was absent stays absent rather
58
+ // than appearing as an explicit undefined.
59
+ for (const field of ['authorizationUrl', 'tokenUrl', 'revokeTokenUrl'] as const) {
60
+ const value = authConfig[field];
61
+ if (typeof value === 'string') {
62
+ resolved[field] = resolveOpenApiTenantUrl(value, tenantName) as T[typeof field];
63
+ }
64
+ }
65
+ return resolved;
66
+ };
67
+
6
68
  export enum ExtendedIntegrationPluginId {
7
69
  GRAPHQL = 'graphqlintegration',
8
70
  REST_API = 'restapiintegration',
@@ -38,6 +38,8 @@ export type BuildPublishStepDetail = {
38
38
  };
39
39
 
40
40
  export type PolicyGatesPublishStepDetail = {
41
+ /** The user stopped this publish, and no check is still running. */
42
+ canceled?: boolean;
41
43
  /**
42
44
  * Whether the server would honor a publish that carries the findings
43
45
  * acknowledgment right now. False while a gate has not reported: its findings