@shipfox/api-integration-github 12.5.0 → 12.6.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +8 -5
- package/dist/api/client.js.map +1 -1
- package/dist/api/github-octokit.d.ts +7 -0
- package/dist/api/github-octokit.d.ts.map +1 -0
- package/dist/api/github-octokit.js +32 -0
- package/dist/api/github-octokit.js.map +1 -0
- package/dist/api/installation-token-provider.d.ts.map +1 -1
- package/dist/api/installation-token-provider.js +4 -2
- package/dist/api/installation-token-provider.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-tools.d.ts.map +1 -1
- package/dist/core/agent-tools.js +124 -78
- package/dist/core/agent-tools.js.map +1 -1
- package/dist/metrics/instance.d.ts +1 -0
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +19 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/api/client.test.ts +25 -5
- package/src/api/client.ts +22 -5
- package/src/api/github-octokit.test.ts +115 -0
- package/src/api/github-octokit.ts +49 -0
- package/src/api/installation-token-provider.test.ts +44 -5
- package/src/api/installation-token-provider.ts +5 -2
- package/src/config.ts +5 -0
- package/src/core/agent-tools.test.ts +261 -74
- package/src/core/agent-tools.ts +191 -80
- package/src/metrics/instance.ts +25 -0
- package/test/env.ts +1 -0
- package/test/fixtures/github-installation-token.ts +8 -0
- package/test/index.ts +4 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
package/src/api/client.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
2
|
+
import {
|
|
3
|
+
GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
4
|
+
GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
5
|
+
} from '#test/index.js';
|
|
2
6
|
import {createGithubApiClient, mapGithubError} from './client.js';
|
|
3
7
|
|
|
8
|
+
const GITHUB_INSTALLATION_TOKEN_PATTERN = /^ghs_[A-Za-z0-9._-]{36,}$/u;
|
|
9
|
+
|
|
4
10
|
const {createInstallationAccessTokenMock, RequestErrorMock} = vi.hoisted(() => {
|
|
5
11
|
class RequestErrorMock extends Error {
|
|
6
12
|
constructor(
|
|
@@ -22,6 +28,9 @@ vi.mock('octokit', () => ({
|
|
|
22
28
|
};
|
|
23
29
|
},
|
|
24
30
|
Octokit: {
|
|
31
|
+
plugin() {
|
|
32
|
+
return this;
|
|
33
|
+
},
|
|
25
34
|
defaults(options: unknown) {
|
|
26
35
|
return {defaults: options};
|
|
27
36
|
},
|
|
@@ -62,7 +71,10 @@ describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
|
|
|
62
71
|
|
|
63
72
|
it('mints a repository-scoped, read-only installation token', async () => {
|
|
64
73
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
65
|
-
data: {
|
|
74
|
+
data: {
|
|
75
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
76
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
77
|
+
},
|
|
66
78
|
});
|
|
67
79
|
const client = createGithubApiClient();
|
|
68
80
|
|
|
@@ -72,9 +84,11 @@ describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
|
|
|
72
84
|
});
|
|
73
85
|
|
|
74
86
|
expect(result).toEqual({
|
|
75
|
-
token:
|
|
87
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
76
88
|
expiresAt: new Date('2026-06-10T12:00:00.000Z'),
|
|
77
89
|
});
|
|
90
|
+
expect(GITHUB_STATELESS_INSTALLATION_TOKEN).toMatch(GITHUB_INSTALLATION_TOKEN_PATTERN);
|
|
91
|
+
expect(GITHUB_STATELESS_INSTALLATION_TOKEN.slice(4).split('.')).toHaveLength(3);
|
|
78
92
|
expect(createInstallationAccessTokenMock).toHaveBeenCalledWith({
|
|
79
93
|
installation_id: 1,
|
|
80
94
|
repository_ids: [42],
|
|
@@ -82,18 +96,24 @@ describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
|
|
|
82
96
|
});
|
|
83
97
|
});
|
|
84
98
|
|
|
85
|
-
it('
|
|
99
|
+
it('passes through a stateful repository-scoped write token', async () => {
|
|
86
100
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
87
|
-
data: {
|
|
101
|
+
data: {
|
|
102
|
+
token: GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
103
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
104
|
+
},
|
|
88
105
|
});
|
|
89
106
|
const client = createGithubApiClient();
|
|
90
107
|
|
|
91
|
-
await client.createInstallationAccessToken({
|
|
108
|
+
const result = await client.createInstallationAccessToken({
|
|
92
109
|
installationId: 1,
|
|
93
110
|
repositoryId: 42,
|
|
94
111
|
permissions: {contents: 'write'},
|
|
95
112
|
});
|
|
96
113
|
|
|
114
|
+
expect(result.token).toBe(GITHUB_STATEFUL_INSTALLATION_TOKEN);
|
|
115
|
+
expect(GITHUB_STATEFUL_INSTALLATION_TOKEN).toMatch(GITHUB_INSTALLATION_TOKEN_PATTERN);
|
|
116
|
+
expect(GITHUB_STATEFUL_INSTALLATION_TOKEN).not.toContain('.');
|
|
97
117
|
expect(createInstallationAccessTokenMock).toHaveBeenCalledWith({
|
|
98
118
|
installation_id: 1,
|
|
99
119
|
repository_ids: [42],
|
package/src/api/client.ts
CHANGED
|
@@ -4,6 +4,11 @@ import ky, {HTTPError, TimeoutError} from 'ky';
|
|
|
4
4
|
import {App, Octokit, RequestError} from 'octokit';
|
|
5
5
|
import {config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey} from '#config.js';
|
|
6
6
|
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
7
|
+
import {recordInstallationTokenFormat} from '#metrics/index.js';
|
|
8
|
+
import {
|
|
9
|
+
getGithubInstallationOctokit,
|
|
10
|
+
githubInstallationTokenFormatPlugin,
|
|
11
|
+
} from './github-octokit.js';
|
|
7
12
|
|
|
8
13
|
const NEXT_PAGE_RE = /[?&]page=(\d+)/;
|
|
9
14
|
const TRAILING_SLASHES_RE = /\/+$/;
|
|
@@ -186,7 +191,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
186
191
|
limit: number;
|
|
187
192
|
cursor?: string | undefined;
|
|
188
193
|
}): Promise<GithubRepositoryPage> {
|
|
189
|
-
const octokit = await
|
|
194
|
+
const octokit = await mapGithubError(() =>
|
|
195
|
+
getGithubInstallationOctokit(this.getApp(), input.installationId),
|
|
196
|
+
);
|
|
190
197
|
const page = cursorToPage(input.cursor);
|
|
191
198
|
const response = await mapGithubError(() =>
|
|
192
199
|
octokit.rest.apps.listReposAccessibleToInstallation({
|
|
@@ -205,7 +212,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
205
212
|
installationId: number;
|
|
206
213
|
repositoryId: number;
|
|
207
214
|
}): Promise<GithubRepository> {
|
|
208
|
-
const octokit = await
|
|
215
|
+
const octokit = await mapGithubError(() =>
|
|
216
|
+
getGithubInstallationOctokit(this.getApp(), input.installationId),
|
|
217
|
+
);
|
|
209
218
|
const response = await mapGithubError(() =>
|
|
210
219
|
octokit.request('GET /repositories/{repository_id}', {
|
|
211
220
|
repository_id: input.repositoryId,
|
|
@@ -223,7 +232,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
223
232
|
limit: number;
|
|
224
233
|
cursor?: string | undefined;
|
|
225
234
|
}): Promise<GithubFilePage> {
|
|
226
|
-
const octokit = await
|
|
235
|
+
const octokit = await mapGithubError(() =>
|
|
236
|
+
getGithubInstallationOctokit(this.getApp(), input.installationId),
|
|
237
|
+
);
|
|
227
238
|
const repository = await this.getRepository({
|
|
228
239
|
installationId: input.installationId,
|
|
229
240
|
repositoryId: input.repositoryId,
|
|
@@ -303,7 +314,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
303
314
|
ref: string;
|
|
304
315
|
path: string;
|
|
305
316
|
}): Promise<GithubFileContent> {
|
|
306
|
-
const octokit = await
|
|
317
|
+
const octokit = await mapGithubError(() =>
|
|
318
|
+
getGithubInstallationOctokit(this.getApp(), input.installationId),
|
|
319
|
+
);
|
|
307
320
|
const repository = await this.getRepository({
|
|
308
321
|
installationId: input.installationId,
|
|
309
322
|
repositoryId: input.repositoryId,
|
|
@@ -363,6 +376,8 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
363
376
|
);
|
|
364
377
|
}
|
|
365
378
|
|
|
379
|
+
recordInstallationTokenFormat(response.data.token);
|
|
380
|
+
|
|
366
381
|
const expiresAt = new Date(response.data.expires_at);
|
|
367
382
|
if (Number.isNaN(expiresAt.getTime())) {
|
|
368
383
|
throw new GithubIntegrationProviderError(
|
|
@@ -383,7 +398,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
383
398
|
this.app = new App({
|
|
384
399
|
appId: config.GITHUB_APP_ID,
|
|
385
400
|
privateKey: normalizedGithubPrivateKey(),
|
|
386
|
-
Octokit: Octokit.defaults({
|
|
401
|
+
Octokit: Octokit.plugin(githubInstallationTokenFormatPlugin).defaults({
|
|
402
|
+
baseUrl: normalizedGithubApiBaseUrl(),
|
|
403
|
+
}),
|
|
387
404
|
});
|
|
388
405
|
}
|
|
389
406
|
return this.app;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {generateKeyPairSync} from 'node:crypto';
|
|
2
|
+
import {once} from 'node:events';
|
|
3
|
+
import {createServer} from 'node:http';
|
|
4
|
+
import {App, Octokit} from 'octokit';
|
|
5
|
+
import {
|
|
6
|
+
GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
7
|
+
GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
8
|
+
} from '#test/index.js';
|
|
9
|
+
import {
|
|
10
|
+
createGithubInstallationTokenFormatPlugin,
|
|
11
|
+
getGithubInstallationOctokit,
|
|
12
|
+
} from './github-octokit.js';
|
|
13
|
+
|
|
14
|
+
const {privateKey} = generateKeyPairSync('rsa', {
|
|
15
|
+
modulusLength: 2048,
|
|
16
|
+
publicKeyEncoding: {type: 'spki', format: 'pem'},
|
|
17
|
+
privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
|
|
18
|
+
});
|
|
19
|
+
const BEARER_AUTHORIZATION = /^bearer /iu;
|
|
20
|
+
|
|
21
|
+
describe('GitHub installation Octokit', () => {
|
|
22
|
+
it.each([
|
|
23
|
+
{
|
|
24
|
+
format: 'stateless',
|
|
25
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
26
|
+
override: 'enabled' as const,
|
|
27
|
+
authorization: `bearer ${GITHUB_STATELESS_INSTALLATION_TOKEN}`,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
format: 'stateful',
|
|
31
|
+
token: GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
32
|
+
override: 'disabled' as const,
|
|
33
|
+
authorization: `token ${GITHUB_STATEFUL_INSTALLATION_TOKEN}`,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
format: 'stateful without an override',
|
|
37
|
+
token: GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
38
|
+
override: undefined,
|
|
39
|
+
authorization: `token ${GITHUB_STATEFUL_INSTALLATION_TOKEN}`,
|
|
40
|
+
},
|
|
41
|
+
])('requests and authenticates with a $format token', async ({
|
|
42
|
+
token,
|
|
43
|
+
override,
|
|
44
|
+
authorization,
|
|
45
|
+
}) => {
|
|
46
|
+
const calls: Array<{
|
|
47
|
+
method: string | undefined;
|
|
48
|
+
path: string | undefined;
|
|
49
|
+
authorization: string | undefined;
|
|
50
|
+
tokenFormatOverride: string | undefined;
|
|
51
|
+
}> = [];
|
|
52
|
+
const server = createServer((request, response) => {
|
|
53
|
+
calls.push({
|
|
54
|
+
method: request.method,
|
|
55
|
+
path: request.url,
|
|
56
|
+
authorization: request.headers.authorization,
|
|
57
|
+
tokenFormatOverride: request.headers['x-github-stateless-s2s-token'] as string | undefined,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (request.method === 'POST' && request.url === '/app/installations/123/access_tokens') {
|
|
61
|
+
response.writeHead(201, {'content-type': 'application/json'}).end(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
token,
|
|
64
|
+
expires_at: '2099-01-01T00:00:00.000Z',
|
|
65
|
+
permissions: {metadata: 'read'},
|
|
66
|
+
repository_selection: 'all',
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (request.method === 'GET' && request.url === '/installation/repositories') {
|
|
73
|
+
response
|
|
74
|
+
.writeHead(200, {'content-type': 'application/json'})
|
|
75
|
+
.end(JSON.stringify({total_count: 0, repositories: []}));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
response.writeHead(404, {'content-type': 'application/json'}).end('{"message":"Not Found"}');
|
|
80
|
+
});
|
|
81
|
+
server.listen({host: '127.0.0.1', port: 0});
|
|
82
|
+
await once(server, 'listening');
|
|
83
|
+
const address = server.address();
|
|
84
|
+
if (!address || typeof address === 'string') throw new Error('Expected TCP server address.');
|
|
85
|
+
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const InstallationTokenOctokit = Octokit.plugin(
|
|
89
|
+
createGithubInstallationTokenFormatPlugin(override),
|
|
90
|
+
).defaults({baseUrl});
|
|
91
|
+
const app = new App({appId: 1, privateKey, Octokit: InstallationTokenOctokit});
|
|
92
|
+
const octokit = await getGithubInstallationOctokit(app, 123, baseUrl);
|
|
93
|
+
|
|
94
|
+
await octokit.rest.apps.listReposAccessibleToInstallation();
|
|
95
|
+
|
|
96
|
+
expect(calls).toEqual([
|
|
97
|
+
{
|
|
98
|
+
method: 'POST',
|
|
99
|
+
path: '/app/installations/123/access_tokens',
|
|
100
|
+
authorization: expect.stringMatching(BEARER_AUTHORIZATION),
|
|
101
|
+
tokenFormatOverride: override,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
method: 'GET',
|
|
105
|
+
path: '/installation/repositories',
|
|
106
|
+
authorization,
|
|
107
|
+
tokenFormatOverride: undefined,
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
} finally {
|
|
111
|
+
server.close();
|
|
112
|
+
await once(server, 'close');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {type App, Octokit} from 'octokit';
|
|
2
|
+
import {config, normalizedGithubApiBaseUrl} from '#config.js';
|
|
3
|
+
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
4
|
+
import {recordInstallationTokenFormat} from '#metrics/index.js';
|
|
5
|
+
|
|
6
|
+
const INSTALLATION_TOKEN_REQUEST_PATH = /\/app\/installations\/[^/]+\/access_tokens(?:\?|$)/u;
|
|
7
|
+
type GithubInstallationTokenFormatOverride = 'enabled' | 'disabled' | undefined;
|
|
8
|
+
|
|
9
|
+
export function createGithubInstallationTokenFormatPlugin(
|
|
10
|
+
override: GithubInstallationTokenFormatOverride,
|
|
11
|
+
): Parameters<typeof Octokit.plugin>[0] {
|
|
12
|
+
return (octokit) => {
|
|
13
|
+
octokit.hook.before('request', (options) => {
|
|
14
|
+
if (
|
|
15
|
+
!override ||
|
|
16
|
+
options.method !== 'POST' ||
|
|
17
|
+
!INSTALLATION_TOKEN_REQUEST_PATH.test(options.url)
|
|
18
|
+
) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
options.headers['x-github-stateless-s2s-token'] = override;
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const githubInstallationTokenFormatPlugin: Parameters<typeof Octokit.plugin>[0] =
|
|
28
|
+
createGithubInstallationTokenFormatPlugin(config.GITHUB_INSTALLATION_TOKEN_FORMAT_OVERRIDE);
|
|
29
|
+
|
|
30
|
+
export async function getGithubInstallationOctokit(
|
|
31
|
+
app: App,
|
|
32
|
+
installationId: number,
|
|
33
|
+
baseUrl = normalizedGithubApiBaseUrl(),
|
|
34
|
+
): Promise<Octokit> {
|
|
35
|
+
const authentication = (await app.octokit.auth({
|
|
36
|
+
type: 'installation',
|
|
37
|
+
installationId,
|
|
38
|
+
})) as {token?: unknown};
|
|
39
|
+
|
|
40
|
+
if (typeof authentication.token !== 'string') {
|
|
41
|
+
throw new GithubIntegrationProviderError(
|
|
42
|
+
'malformed-provider-response',
|
|
43
|
+
'GitHub installation authentication did not include a token',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
recordInstallationTokenFormat(authentication.token);
|
|
48
|
+
return new Octokit({auth: authentication.token, baseUrl});
|
|
49
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type {GetIntegrationConnectionByIdFn} from '@shipfox/api-integration-spi';
|
|
2
2
|
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
5
|
+
GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
6
|
+
githubInstallationFactory,
|
|
7
|
+
} from '#test/index.js';
|
|
4
8
|
import {encodeInstallationTokenEnvelope} from './installation-token-envelope.js';
|
|
5
9
|
import {createGithubInstallationTokenProvider} from './installation-token-provider.js';
|
|
6
10
|
|
|
11
|
+
const GITHUB_INSTALLATION_TOKEN_PATTERN = /^ghs_[A-Za-z0-9._-]{36,}$/u;
|
|
12
|
+
|
|
7
13
|
const {appOptions, createInstallationAccessTokenMock, RequestErrorMock} = vi.hoisted(() => ({
|
|
8
14
|
appOptions: [] as unknown[],
|
|
9
15
|
createInstallationAccessTokenMock: vi.fn(),
|
|
@@ -28,6 +34,9 @@ vi.mock('octokit', () => ({
|
|
|
28
34
|
}
|
|
29
35
|
},
|
|
30
36
|
Octokit: {
|
|
37
|
+
plugin() {
|
|
38
|
+
return this;
|
|
39
|
+
},
|
|
31
40
|
defaults(options: unknown) {
|
|
32
41
|
return {defaults: options};
|
|
33
42
|
},
|
|
@@ -47,26 +56,49 @@ describe('GithubInstallationTokenProvider', () => {
|
|
|
47
56
|
|
|
48
57
|
it('mints a broad installation token on a cache miss', async () => {
|
|
49
58
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
50
|
-
data: {
|
|
59
|
+
data: {
|
|
60
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
61
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
62
|
+
},
|
|
51
63
|
});
|
|
52
64
|
const provider = createGithubInstallationTokenProvider();
|
|
53
65
|
|
|
54
66
|
const result = await provider.getInstallationAccessToken(1);
|
|
55
67
|
|
|
56
68
|
expect(result).toEqual({
|
|
57
|
-
token:
|
|
69
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
58
70
|
expiresAt: new Date('2026-06-10T12:00:00.000Z'),
|
|
59
71
|
});
|
|
72
|
+
expect(GITHUB_STATELESS_INSTALLATION_TOKEN).toMatch(GITHUB_INSTALLATION_TOKEN_PATTERN);
|
|
73
|
+
expect(GITHUB_STATELESS_INSTALLATION_TOKEN.slice(4).split('.')).toHaveLength(3);
|
|
60
74
|
expect(createInstallationAccessTokenMock).toHaveBeenCalledWith({
|
|
61
75
|
installation_id: 1,
|
|
62
76
|
});
|
|
63
77
|
});
|
|
64
78
|
|
|
79
|
+
it('passes through a stateful broad installation token', async () => {
|
|
80
|
+
createInstallationAccessTokenMock.mockResolvedValue({
|
|
81
|
+
data: {
|
|
82
|
+
token: GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
83
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const provider = createGithubInstallationTokenProvider();
|
|
87
|
+
|
|
88
|
+
const result = await provider.getInstallationAccessToken(1);
|
|
89
|
+
|
|
90
|
+
expect(result.token).toBe(GITHUB_STATEFUL_INSTALLATION_TOKEN);
|
|
91
|
+
expect(GITHUB_STATEFUL_INSTALLATION_TOKEN).not.toContain('.');
|
|
92
|
+
});
|
|
93
|
+
|
|
65
94
|
it('returns a cached token without a second mint', async () => {
|
|
66
95
|
vi.useFakeTimers();
|
|
67
96
|
vi.setSystemTime(new Date('2026-06-10T11:00:00.000Z'));
|
|
68
97
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
69
|
-
data: {
|
|
98
|
+
data: {
|
|
99
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
100
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
101
|
+
},
|
|
70
102
|
});
|
|
71
103
|
const provider = createGithubInstallationTokenProvider();
|
|
72
104
|
|
|
@@ -151,7 +183,10 @@ describe('GithubInstallationTokenProvider', () => {
|
|
|
151
183
|
updatedAt: new Date(),
|
|
152
184
|
});
|
|
153
185
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
154
|
-
data: {
|
|
186
|
+
data: {
|
|
187
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
188
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
189
|
+
},
|
|
155
190
|
});
|
|
156
191
|
const provider = createGithubInstallationTokenProvider({
|
|
157
192
|
getIntegrationConnectionById,
|
|
@@ -174,6 +209,10 @@ describe('GithubInstallationTokenProvider', () => {
|
|
|
174
209
|
const second = await provider.getInstallationAccessToken(installationId);
|
|
175
210
|
|
|
176
211
|
expect(first).toEqual(second);
|
|
212
|
+
expect(first.token).toBe(GITHUB_STATELESS_INSTALLATION_TOKEN);
|
|
213
|
+
expect(values.get(`${workspaceId}:${installationId}`)).toContain(
|
|
214
|
+
GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
215
|
+
);
|
|
177
216
|
expect(lockCalls).toBe(1);
|
|
178
217
|
expect(createInstallationAccessTokenMock).toHaveBeenCalledTimes(1);
|
|
179
218
|
});
|
|
@@ -4,8 +4,9 @@ import {config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey} from '#c
|
|
|
4
4
|
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
5
5
|
import {withInstallationTokenLock} from '#db/installation-token-lock.js';
|
|
6
6
|
import {getGithubInstallationByInstallationId} from '#db/installations.js';
|
|
7
|
-
import {recordInstallationTokenLookup} from '#metrics/index.js';
|
|
7
|
+
import {recordInstallationTokenFormat, recordInstallationTokenLookup} from '#metrics/index.js';
|
|
8
8
|
import {type GithubInstallationAccessToken, mapGithubError} from './client.js';
|
|
9
|
+
import {githubInstallationTokenFormatPlugin} from './github-octokit.js';
|
|
9
10
|
import {
|
|
10
11
|
githubInstallationTokenNamespace,
|
|
11
12
|
TOKEN_REFRESH_MARGIN_MS,
|
|
@@ -66,6 +67,8 @@ class OctokitGithubInstallationTokenProvider implements GithubInstallationTokenP
|
|
|
66
67
|
);
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
recordInstallationTokenFormat(response.data.token);
|
|
71
|
+
|
|
69
72
|
const expiresAt = new Date(response.data.expires_at);
|
|
70
73
|
if (Number.isNaN(expiresAt.getTime())) {
|
|
71
74
|
throw new GithubIntegrationProviderError(
|
|
@@ -86,7 +89,7 @@ class OctokitGithubInstallationTokenProvider implements GithubInstallationTokenP
|
|
|
86
89
|
this.app = new App({
|
|
87
90
|
appId: config.GITHUB_APP_ID,
|
|
88
91
|
privateKey: normalizedGithubPrivateKey(),
|
|
89
|
-
Octokit: Octokit.defaults({
|
|
92
|
+
Octokit: Octokit.plugin(githubInstallationTokenFormatPlugin).defaults({
|
|
90
93
|
baseUrl: normalizedGithubApiBaseUrl(),
|
|
91
94
|
throttle: {
|
|
92
95
|
onRateLimit: (
|
package/src/config.ts
CHANGED
|
@@ -29,6 +29,11 @@ export const config = createConfig({
|
|
|
29
29
|
desc: 'Base URL used for GitHub REST API requests. Set this only for GitHub Enterprise Server or a compatible test server.',
|
|
30
30
|
default: 'https://api.github.com',
|
|
31
31
|
}),
|
|
32
|
+
GITHUB_INSTALLATION_TOKEN_FORMAT_OVERRIDE: str({
|
|
33
|
+
desc: 'Temporary GitHub installation token format override. Set this to enabled to request stateless tokens, disabled to request stateful tokens, or leave it unset to follow the GitHub rollout.',
|
|
34
|
+
choices: ['enabled', 'disabled'] as const,
|
|
35
|
+
default: undefined,
|
|
36
|
+
}),
|
|
32
37
|
GITHUB_INSTALL_STATE_SECRET: str({
|
|
33
38
|
desc: 'Secret used to sign the state token that protects the GitHub App install flow. Required.',
|
|
34
39
|
}),
|