@superblocksteam/shared 0.9615.3 → 0.9616.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/database-lifecycle/appDatabaseImport.d.ts +12 -4
- package/dist/database-lifecycle/appDatabaseImport.d.ts.map +1 -1
- package/dist/database-lifecycle/appDatabaseImport.js +21 -3
- package/dist/database-lifecycle/appDatabaseImport.js.map +1 -1
- package/dist/database-lifecycle/appDatabaseImport.test.js +17 -0
- package/dist/database-lifecycle/appDatabaseImport.test.js.map +1 -1
- package/dist/database-lifecycle/index.d.ts +4 -2
- package/dist/database-lifecycle/index.d.ts.map +1 -1
- package/dist/database-lifecycle/index.js +6 -2
- package/dist/database-lifecycle/index.js.map +1 -1
- package/dist/database-lifecycle/index.test.js +1 -0
- package/dist/database-lifecycle/index.test.js.map +1 -1
- package/dist/plugins/templates/shared/auth.d.ts +17 -1
- package/dist/plugins/templates/shared/auth.d.ts.map +1 -1
- package/dist/plugins/templates/shared/auth.js +139 -2
- package/dist/plugins/templates/shared/auth.js.map +1 -1
- package/dist/plugins/templates/shared/auth.test.js +86 -0
- package/dist/plugins/templates/shared/auth.test.js.map +1 -1
- package/dist/plugins/templates/shared/openapi.d.ts +6 -1
- package/dist/plugins/templates/shared/openapi.d.ts.map +1 -1
- package/dist/plugins/templates/shared/openapi.js +7 -3
- package/dist/plugins/templates/shared/openapi.js.map +1 -1
- package/dist/plugins/templates/shared/restapiintegration.d.ts +1 -0
- package/dist/plugins/templates/shared/restapiintegration.d.ts.map +1 -1
- package/dist/plugins/templates/shared/restapiintegration.js +3 -1
- package/dist/plugins/templates/shared/restapiintegration.js.map +1 -1
- package/dist/plugins/templates/zendesk.d.ts +1 -0
- package/dist/plugins/templates/zendesk.d.ts.map +1 -1
- package/dist/plugins/templates/zendesk.js +41 -3
- package/dist/plugins/templates/zendesk.js.map +1 -1
- package/dist/plugins/templates/zendesk.test.d.ts +2 -0
- package/dist/plugins/templates/zendesk.test.d.ts.map +1 -0
- package/dist/plugins/templates/zendesk.test.js +79 -0
- package/dist/plugins/templates/zendesk.test.js.map +1 -0
- package/dist/socket/protocol.d.ts +23 -0
- package/dist/socket/protocol.d.ts.map +1 -1
- package/dist/socket/socketWithClientAuth.d.ts +3 -0
- package/dist/socket/socketWithClientAuth.d.ts.map +1 -1
- package/dist/socket/socketWithClientAuth.js +12 -1
- package/dist/socket/socketWithClientAuth.js.map +1 -1
- package/dist/socket/socketWithClientAuth.test.d.ts +2 -0
- package/dist/socket/socketWithClientAuth.test.d.ts.map +1 -0
- package/dist/socket/socketWithClientAuth.test.js +106 -0
- package/dist/socket/socketWithClientAuth.test.js.map +1 -0
- package/dist/types/datasource/auth.d.ts +2 -0
- package/dist/types/datasource/auth.d.ts.map +1 -1
- package/dist/types/datasource/auth.js.map +1 -1
- package/dist/types/datasource/index.d.ts +15 -0
- package/dist/types/datasource/index.d.ts.map +1 -1
- package/dist/types/datasource/index.js +31 -0
- package/dist/types/datasource/index.js.map +1 -1
- package/dist/types/datasource/index.test.js +20 -0
- package/dist/types/datasource/index.test.js.map +1 -1
- package/dist/types/deployment/index.d.ts +2 -0
- package/dist/types/deployment/index.d.ts.map +1 -1
- package/dist/types/event/index.d.ts +3 -0
- package/dist/types/event/index.d.ts.map +1 -1
- package/dist/types/event/index.js +3 -0
- package/dist/types/event/index.js.map +1 -1
- package/dist/types/observability/index.d.ts +1 -1
- package/dist/types/observability/index.d.ts.map +1 -1
- package/dist/types/observability/index.js.map +1 -1
- package/dist/types/plugin/form.d.ts +31 -0
- package/dist/types/plugin/form.d.ts.map +1 -1
- package/dist/types/plugin/form.js +39 -1
- package/dist/types/plugin/form.js.map +1 -1
- package/dist/types/plugin/form.test.js +24 -0
- package/dist/types/plugin/form.test.js.map +1 -1
- package/dist/types/plugin/integration.d.ts +32 -0
- package/dist/types/plugin/integration.d.ts.map +1 -1
- package/dist/types/plugin/integration.js +58 -1
- package/dist/types/plugin/integration.js.map +1 -1
- package/dist/types/plugin/integration.test.d.ts +2 -0
- package/dist/types/plugin/integration.test.d.ts.map +1 -0
- package/dist/types/plugin/integration.test.js +39 -0
- package/dist/types/plugin/integration.test.js.map +1 -0
- package/dist-esm/database-lifecycle/appDatabaseImport.d.ts +12 -4
- package/dist-esm/database-lifecycle/appDatabaseImport.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/appDatabaseImport.js +21 -3
- package/dist-esm/database-lifecycle/appDatabaseImport.js.map +1 -1
- package/dist-esm/database-lifecycle/appDatabaseImport.test.js +17 -0
- package/dist-esm/database-lifecycle/appDatabaseImport.test.js.map +1 -1
- package/dist-esm/database-lifecycle/index.d.ts +4 -2
- package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/index.js +5 -1
- package/dist-esm/database-lifecycle/index.js.map +1 -1
- package/dist-esm/database-lifecycle/index.test.js +1 -0
- package/dist-esm/database-lifecycle/index.test.js.map +1 -1
- package/dist-esm/plugins/templates/shared/auth.d.ts +17 -1
- package/dist-esm/plugins/templates/shared/auth.d.ts.map +1 -1
- package/dist-esm/plugins/templates/shared/auth.js +140 -3
- package/dist-esm/plugins/templates/shared/auth.js.map +1 -1
- package/dist-esm/plugins/templates/shared/auth.test.js +87 -1
- package/dist-esm/plugins/templates/shared/auth.test.js.map +1 -1
- package/dist-esm/plugins/templates/shared/openapi.d.ts +6 -1
- package/dist-esm/plugins/templates/shared/openapi.d.ts.map +1 -1
- package/dist-esm/plugins/templates/shared/openapi.js +7 -3
- package/dist-esm/plugins/templates/shared/openapi.js.map +1 -1
- package/dist-esm/plugins/templates/shared/restapiintegration.d.ts +1 -0
- package/dist-esm/plugins/templates/shared/restapiintegration.d.ts.map +1 -1
- package/dist-esm/plugins/templates/shared/restapiintegration.js +3 -1
- package/dist-esm/plugins/templates/shared/restapiintegration.js.map +1 -1
- package/dist-esm/plugins/templates/zendesk.d.ts +1 -0
- package/dist-esm/plugins/templates/zendesk.d.ts.map +1 -1
- package/dist-esm/plugins/templates/zendesk.js +42 -4
- package/dist-esm/plugins/templates/zendesk.js.map +1 -1
- package/dist-esm/plugins/templates/zendesk.test.d.ts +2 -0
- package/dist-esm/plugins/templates/zendesk.test.d.ts.map +1 -0
- package/dist-esm/plugins/templates/zendesk.test.js +77 -0
- package/dist-esm/plugins/templates/zendesk.test.js.map +1 -0
- package/dist-esm/socket/protocol.d.ts +23 -0
- package/dist-esm/socket/protocol.d.ts.map +1 -1
- package/dist-esm/socket/socketWithClientAuth.d.ts +3 -0
- package/dist-esm/socket/socketWithClientAuth.d.ts.map +1 -1
- package/dist-esm/socket/socketWithClientAuth.js +12 -1
- package/dist-esm/socket/socketWithClientAuth.js.map +1 -1
- package/dist-esm/socket/socketWithClientAuth.test.d.ts +2 -0
- package/dist-esm/socket/socketWithClientAuth.test.d.ts.map +1 -0
- package/dist-esm/socket/socketWithClientAuth.test.js +101 -0
- package/dist-esm/socket/socketWithClientAuth.test.js.map +1 -0
- package/dist-esm/types/datasource/auth.d.ts +2 -0
- package/dist-esm/types/datasource/auth.d.ts.map +1 -1
- package/dist-esm/types/datasource/auth.js.map +1 -1
- package/dist-esm/types/datasource/index.d.ts +15 -0
- package/dist-esm/types/datasource/index.d.ts.map +1 -1
- package/dist-esm/types/datasource/index.js +29 -0
- package/dist-esm/types/datasource/index.js.map +1 -1
- package/dist-esm/types/datasource/index.test.js +21 -1
- package/dist-esm/types/datasource/index.test.js.map +1 -1
- package/dist-esm/types/deployment/index.d.ts +2 -0
- package/dist-esm/types/deployment/index.d.ts.map +1 -1
- package/dist-esm/types/event/index.d.ts +3 -0
- package/dist-esm/types/event/index.d.ts.map +1 -1
- package/dist-esm/types/event/index.js +3 -0
- package/dist-esm/types/event/index.js.map +1 -1
- package/dist-esm/types/observability/index.d.ts +1 -1
- package/dist-esm/types/observability/index.d.ts.map +1 -1
- package/dist-esm/types/observability/index.js.map +1 -1
- package/dist-esm/types/plugin/form.d.ts +31 -0
- package/dist-esm/types/plugin/form.d.ts.map +1 -1
- package/dist-esm/types/plugin/form.js +36 -0
- package/dist-esm/types/plugin/form.js.map +1 -1
- package/dist-esm/types/plugin/form.test.js +25 -1
- package/dist-esm/types/plugin/form.test.js.map +1 -1
- package/dist-esm/types/plugin/integration.d.ts +32 -0
- package/dist-esm/types/plugin/integration.d.ts.map +1 -1
- package/dist-esm/types/plugin/integration.js +54 -0
- package/dist-esm/types/plugin/integration.js.map +1 -1
- package/dist-esm/types/plugin/integration.test.d.ts +2 -0
- package/dist-esm/types/plugin/integration.test.d.ts.map +1 -0
- package/dist-esm/types/plugin/integration.test.js +37 -0
- package/dist-esm/types/plugin/integration.test.js.map +1 -0
- package/package.json +1 -1
- package/src/database-lifecycle/appDatabaseImport.test.ts +21 -0
- package/src/database-lifecycle/appDatabaseImport.ts +37 -4
- package/src/database-lifecycle/index.test.ts +1 -0
- package/src/database-lifecycle/index.ts +7 -1
- package/src/plugins/templates/shared/auth.test.ts +119 -1
- package/src/plugins/templates/shared/auth.ts +165 -2
- package/src/plugins/templates/shared/openapi.ts +15 -3
- package/src/plugins/templates/shared/restapiintegration.ts +3 -1
- package/src/plugins/templates/zendesk.test.ts +98 -0
- package/src/plugins/templates/zendesk.ts +44 -4
- package/src/socket/protocol.ts +19 -0
- package/src/socket/socketWithClientAuth.test.ts +117 -0
- package/src/socket/socketWithClientAuth.ts +15 -1
- package/src/types/datasource/auth.ts +4 -0
- package/src/types/datasource/index.test.ts +24 -1
- package/src/types/datasource/index.ts +31 -0
- package/src/types/deployment/index.ts +2 -0
- package/src/types/event/index.ts +3 -0
- package/src/types/observability/index.ts +2 -1
- package/src/types/plugin/form.test.ts +32 -1
- package/src/types/plugin/form.ts +54 -0
- package/src/types/plugin/integration.test.ts +45 -0
- package/src/types/plugin/integration.ts +62 -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
|
-
|
|
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
|
}
|
package/src/types/event/index.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/types/plugin/form.ts
CHANGED
|
@@ -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',
|