@shipfox/api-integration-github 2.0.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 +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +138 -0
- package/LICENSE +21 -0
- package/dist/api/client.d.ts +90 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +306 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/installation-token-envelope.d.ts +36 -0
- package/dist/api/installation-token-envelope.d.ts.map +1 -0
- package/dist/api/installation-token-envelope.js +123 -0
- package/dist/api/installation-token-envelope.js.map +1 -0
- package/dist/api/installation-token-provider.d.ts +23 -0
- package/dist/api/installation-token-provider.d.ts.map +1 -0
- package/dist/api/installation-token-provider.js +130 -0
- package/dist/api/installation-token-provider.js.map +1 -0
- package/dist/api/shared-installation-token-cache.d.ts +43 -0
- package/dist/api/shared-installation-token-cache.d.ts.map +1 -0
- package/dist/api/shared-installation-token-cache.js +224 -0
- package/dist/api/shared-installation-token-cache.js.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +44 -0
- package/dist/config.js.map +1 -0
- package/dist/core/actions-logs.d.ts +47 -0
- package/dist/core/actions-logs.d.ts.map +1 -0
- package/dist/core/actions-logs.js +134 -0
- package/dist/core/actions-logs.js.map +1 -0
- package/dist/core/agent-tool-selection.d.ts +4 -0
- package/dist/core/agent-tool-selection.d.ts.map +1 -0
- package/dist/core/agent-tool-selection.js +38 -0
- package/dist/core/agent-tool-selection.js.map +1 -0
- package/dist/core/agent-tools.d.ts +50 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +1122 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/errors.d.ts +15 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +22 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/install.d.ts +38 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +64 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/source-control.d.ts +15 -0
- package/dist/core/source-control.d.ts.map +1 -0
- package/dist/core/source-control.js +175 -0
- package/dist/core/source-control.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +54 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/webhook.d.ts +20 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +216 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +433 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/installation-token-lock.d.ts +8 -0
- package/dist/db/installation-token-lock.d.ts.map +1 -0
- package/dist/db/installation-token-lock.js +37 -0
- package/dist/db/installation-token-lock.js.map +1 -0
- package/dist/db/installations.d.ts +33 -0
- package/dist/db/installations.d.ts.map +1 -0
- package/dist/db/installations.js +56 -0
- package/dist/db/installations.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/installations.d.ts +218 -0
- package/dist/db/schema/installations.d.ts.map +1 -0
- package/dist/db/schema/installations.js +46 -0
- package/dist/db/schema/installations.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +14 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +75 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +15 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +10 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts +11 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.js +57 -0
- package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +5 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +46 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +13 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +77 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +16 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +110 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +17 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +71 -0
- package/src/api/client.test.ts +94 -0
- package/src/api/client.ts +525 -0
- package/src/api/installation-token-envelope.ts +171 -0
- package/src/api/installation-token-provider.test.ts +241 -0
- package/src/api/installation-token-provider.ts +224 -0
- package/src/api/shared-installation-token-cache.test.ts +294 -0
- package/src/api/shared-installation-token-cache.ts +346 -0
- package/src/config.test.ts +14 -0
- package/src/config.ts +47 -0
- package/src/connection-external-url.test.ts +78 -0
- package/src/core/actions-logs.test.ts +156 -0
- package/src/core/actions-logs.ts +186 -0
- package/src/core/agent-tool-selection.test.ts +41 -0
- package/src/core/agent-tool-selection.ts +42 -0
- package/src/core/agent-tools.test.ts +539 -0
- package/src/core/agent-tools.ts +1269 -0
- package/src/core/errors.ts +23 -0
- package/src/core/install.test.ts +238 -0
- package/src/core/install.ts +119 -0
- package/src/core/source-control.test.ts +327 -0
- package/src/core/source-control.ts +223 -0
- package/src/core/state.test.ts +43 -0
- package/src/core/state.ts +85 -0
- package/src/core/webhook.test.ts +522 -0
- package/src/core/webhook.ts +296 -0
- package/src/db/db.ts +18 -0
- package/src/db/installation-token-lock.test.ts +68 -0
- package/src/db/installation-token-lock.ts +39 -0
- package/src/db/installations.test.ts +67 -0
- package/src/db/installations.ts +108 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/installations.ts +46 -0
- package/src/index.ts +124 -0
- package/src/metrics/index.ts +1 -0
- package/src/metrics/instance.ts +82 -0
- package/src/presentation/dto/integrations.ts +8 -0
- package/src/presentation/e2eRoutes/create-connection.ts +70 -0
- package/src/presentation/e2eRoutes/index.test.ts +126 -0
- package/src/presentation/e2eRoutes/index.ts +14 -0
- package/src/presentation/routes/errors.ts +43 -0
- package/src/presentation/routes/install.test.ts +279 -0
- package/src/presentation/routes/install.ts +93 -0
- package/src/presentation/routes/webhooks.test.ts +560 -0
- package/src/presentation/routes/webhooks.ts +115 -0
- package/test/env.ts +16 -0
- package/test/factories/github-installation.ts +35 -0
- package/test/factories/index.ts +1 -0
- package/test/fixtures/github-webhook.ts +20 -0
- package/test/globalSetup.ts +14 -0
- package/test/index.ts +2 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type {GetIntegrationConnectionByIdFn} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
3
|
+
import {githubInstallationFactory} from '#test/index.js';
|
|
4
|
+
import {encodeInstallationTokenEnvelope} from './installation-token-envelope.js';
|
|
5
|
+
import {createGithubInstallationTokenProvider} from './installation-token-provider.js';
|
|
6
|
+
|
|
7
|
+
const {appOptions, createInstallationAccessTokenMock, RequestErrorMock} = vi.hoisted(() => ({
|
|
8
|
+
appOptions: [] as unknown[],
|
|
9
|
+
createInstallationAccessTokenMock: vi.fn(),
|
|
10
|
+
RequestErrorMock: class RequestError extends Error {
|
|
11
|
+
constructor(
|
|
12
|
+
message: string,
|
|
13
|
+
public readonly status: number,
|
|
14
|
+
) {
|
|
15
|
+
super(message);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('octokit', () => ({
|
|
21
|
+
App: class App {
|
|
22
|
+
octokit = {
|
|
23
|
+
rest: {apps: {createInstallationAccessToken: createInstallationAccessTokenMock}},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
constructor(options: unknown) {
|
|
27
|
+
appOptions.push(options);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
Octokit: {
|
|
31
|
+
defaults(options: unknown) {
|
|
32
|
+
return {defaults: options};
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
RequestError: RequestErrorMock,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
describe('GithubInstallationTokenProvider', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
appOptions.length = 0;
|
|
41
|
+
createInstallationAccessTokenMock.mockReset();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.useRealTimers();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('mints a broad installation token on a cache miss', async () => {
|
|
49
|
+
createInstallationAccessTokenMock.mockResolvedValue({
|
|
50
|
+
data: {token: 'ghs_installationtoken', expires_at: '2026-06-10T12:00:00.000Z'},
|
|
51
|
+
});
|
|
52
|
+
const provider = createGithubInstallationTokenProvider();
|
|
53
|
+
|
|
54
|
+
const result = await provider.getInstallationAccessToken(1);
|
|
55
|
+
|
|
56
|
+
expect(result).toEqual({
|
|
57
|
+
token: 'ghs_installationtoken',
|
|
58
|
+
expiresAt: new Date('2026-06-10T12:00:00.000Z'),
|
|
59
|
+
});
|
|
60
|
+
expect(createInstallationAccessTokenMock).toHaveBeenCalledWith({
|
|
61
|
+
installation_id: 1,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns a cached token without a second mint', async () => {
|
|
66
|
+
vi.useFakeTimers();
|
|
67
|
+
vi.setSystemTime(new Date('2026-06-10T11:00:00.000Z'));
|
|
68
|
+
createInstallationAccessTokenMock.mockResolvedValue({
|
|
69
|
+
data: {token: 'ghs_installationtoken', expires_at: '2026-06-10T12:00:00.000Z'},
|
|
70
|
+
});
|
|
71
|
+
const provider = createGithubInstallationTokenProvider();
|
|
72
|
+
|
|
73
|
+
const first = await provider.getInstallationAccessToken(1);
|
|
74
|
+
const second = await provider.getInstallationAccessToken(1);
|
|
75
|
+
|
|
76
|
+
expect(first).toEqual(second);
|
|
77
|
+
expect(createInstallationAccessTokenMock).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('mints a fresh token inside the expiry refresh margin', async () => {
|
|
81
|
+
vi.useFakeTimers();
|
|
82
|
+
vi.setSystemTime(new Date('2026-06-10T12:00:00.000Z'));
|
|
83
|
+
createInstallationAccessTokenMock
|
|
84
|
+
.mockResolvedValueOnce({
|
|
85
|
+
data: {token: 'ghs_first', expires_at: '2026-06-10T12:10:00.000Z'},
|
|
86
|
+
})
|
|
87
|
+
.mockResolvedValueOnce({
|
|
88
|
+
data: {token: 'ghs_second', expires_at: '2026-06-10T13:00:00.000Z'},
|
|
89
|
+
});
|
|
90
|
+
const provider = createGithubInstallationTokenProvider();
|
|
91
|
+
|
|
92
|
+
const first = await provider.getInstallationAccessToken(1);
|
|
93
|
+
vi.setSystemTime(new Date('2026-06-10T12:06:00.000Z'));
|
|
94
|
+
const second = await provider.getInstallationAccessToken(1);
|
|
95
|
+
|
|
96
|
+
expect(first.token).toBe('ghs_first');
|
|
97
|
+
expect(second.token).toBe('ghs_second');
|
|
98
|
+
expect(createInstallationAccessTokenMock).toHaveBeenCalledTimes(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('dedupes concurrent cold-cache mints for one installation', async () => {
|
|
102
|
+
let resolveMint: (
|
|
103
|
+
value: Awaited<ReturnType<typeof createInstallationAccessTokenMock>>,
|
|
104
|
+
) => void = (_value) => {
|
|
105
|
+
throw new Error('Mint promise was not initialized');
|
|
106
|
+
};
|
|
107
|
+
createInstallationAccessTokenMock.mockReturnValue(
|
|
108
|
+
new Promise((resolve) => {
|
|
109
|
+
resolveMint = resolve;
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
const provider = createGithubInstallationTokenProvider();
|
|
113
|
+
|
|
114
|
+
const first = provider.getInstallationAccessToken(1);
|
|
115
|
+
const second = provider.getInstallationAccessToken(1);
|
|
116
|
+
resolveMint({
|
|
117
|
+
data: {token: 'ghs_installationtoken', expires_at: '2026-06-10T12:00:00.000Z'},
|
|
118
|
+
});
|
|
119
|
+
const results = await Promise.all([first, second]);
|
|
120
|
+
|
|
121
|
+
expect(results).toEqual([
|
|
122
|
+
{token: 'ghs_installationtoken', expiresAt: new Date('2026-06-10T12:00:00.000Z')},
|
|
123
|
+
{token: 'ghs_installationtoken', expiresAt: new Date('2026-06-10T12:00:00.000Z')},
|
|
124
|
+
]);
|
|
125
|
+
expect(createInstallationAccessTokenMock).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('composes the RAM tier over the shared token cache', async () => {
|
|
129
|
+
vi.useFakeTimers();
|
|
130
|
+
vi.setSystemTime(new Date('2026-06-10T11:00:00.000Z'));
|
|
131
|
+
const installationId = Math.floor(Math.random() * 1_000_000_000);
|
|
132
|
+
const connectionId = crypto.randomUUID();
|
|
133
|
+
const workspaceId = crypto.randomUUID();
|
|
134
|
+
await githubInstallationFactory.create({installationId: String(installationId), connectionId});
|
|
135
|
+
const values = new Map<string, string>();
|
|
136
|
+
let lockCalls = 0;
|
|
137
|
+
function withLock<T>(_installationId: number, fn: () => Promise<T>) {
|
|
138
|
+
lockCalls += 1;
|
|
139
|
+
return fn().then((value) => ({acquired: true as const, value}));
|
|
140
|
+
}
|
|
141
|
+
const getIntegrationConnectionById: GetIntegrationConnectionByIdFn = () =>
|
|
142
|
+
Promise.resolve({
|
|
143
|
+
id: connectionId,
|
|
144
|
+
workspaceId,
|
|
145
|
+
provider: 'github',
|
|
146
|
+
externalAccountId: String(installationId),
|
|
147
|
+
slug: 'github_shipfox',
|
|
148
|
+
displayName: 'GitHub shipfox',
|
|
149
|
+
lifecycleStatus: 'active',
|
|
150
|
+
createdAt: new Date(),
|
|
151
|
+
updatedAt: new Date(),
|
|
152
|
+
});
|
|
153
|
+
createInstallationAccessTokenMock.mockResolvedValue({
|
|
154
|
+
data: {token: 'ghs_installationtoken', expires_at: '2026-06-10T12:00:00.000Z'},
|
|
155
|
+
});
|
|
156
|
+
const provider = createGithubInstallationTokenProvider({
|
|
157
|
+
getIntegrationConnectionById,
|
|
158
|
+
secretStore: {
|
|
159
|
+
read: (readWorkspaceId, readInstallationId) =>
|
|
160
|
+
Promise.resolve(values.get(`${readWorkspaceId}:${readInstallationId}`) ?? null),
|
|
161
|
+
write: (writeWorkspaceId, writeInstallationId, envelope) => {
|
|
162
|
+
values.set(
|
|
163
|
+
`${writeWorkspaceId}:${writeInstallationId}`,
|
|
164
|
+
encodeInstallationTokenEnvelope(envelope),
|
|
165
|
+
);
|
|
166
|
+
return Promise.resolve();
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
withLock,
|
|
170
|
+
now: () => new Date(),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const first = await provider.getInstallationAccessToken(installationId);
|
|
174
|
+
const second = await provider.getInstallationAccessToken(installationId);
|
|
175
|
+
|
|
176
|
+
expect(first).toEqual(second);
|
|
177
|
+
expect(lockCalls).toBe(1);
|
|
178
|
+
expect(createInstallationAccessTokenMock).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('configures throttle retry handlers on the mint octokit', async () => {
|
|
182
|
+
createInstallationAccessTokenMock.mockResolvedValue({
|
|
183
|
+
data: {token: 'ghs_installationtoken', expires_at: '2026-06-10T12:00:00.000Z'},
|
|
184
|
+
});
|
|
185
|
+
const provider = createGithubInstallationTokenProvider();
|
|
186
|
+
|
|
187
|
+
await provider.getInstallationAccessToken(1);
|
|
188
|
+
|
|
189
|
+
expect(appOptions).toEqual([
|
|
190
|
+
expect.objectContaining({
|
|
191
|
+
Octokit: {
|
|
192
|
+
defaults: {
|
|
193
|
+
baseUrl: 'https://api.github.com',
|
|
194
|
+
throttle: {
|
|
195
|
+
onRateLimit: expect.any(Function),
|
|
196
|
+
onSecondaryRateLimit: expect.any(Function),
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('maps missing installations to an installation-not-found provider error', async () => {
|
|
205
|
+
createInstallationAccessTokenMock.mockRejectedValue(new RequestErrorMock('Not Found', 404));
|
|
206
|
+
const provider = createGithubInstallationTokenProvider();
|
|
207
|
+
|
|
208
|
+
const result = provider.getInstallationAccessToken(1);
|
|
209
|
+
|
|
210
|
+
await expect(result).rejects.toMatchObject({
|
|
211
|
+
reason: 'installation-not-found',
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('rejects a response without a token', async () => {
|
|
216
|
+
createInstallationAccessTokenMock.mockResolvedValue({
|
|
217
|
+
data: {expires_at: '2026-06-10T12:00:00.000Z'},
|
|
218
|
+
});
|
|
219
|
+
const provider = createGithubInstallationTokenProvider();
|
|
220
|
+
|
|
221
|
+
const result = provider.getInstallationAccessToken(1);
|
|
222
|
+
|
|
223
|
+
await expect(result).rejects.toMatchObject({
|
|
224
|
+
reason: 'malformed-provider-response',
|
|
225
|
+
});
|
|
226
|
+
await expect(result).rejects.toBeInstanceOf(GithubIntegrationProviderError);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('rejects a response with a missing or unparseable expiry', async () => {
|
|
230
|
+
createInstallationAccessTokenMock.mockResolvedValue({
|
|
231
|
+
data: {token: 'ghs_installationtoken'},
|
|
232
|
+
});
|
|
233
|
+
const provider = createGithubInstallationTokenProvider();
|
|
234
|
+
|
|
235
|
+
const result = provider.getInstallationAccessToken(1);
|
|
236
|
+
|
|
237
|
+
await expect(result).rejects.toMatchObject({
|
|
238
|
+
reason: 'malformed-provider-response',
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type {GetIntegrationConnectionByIdFn} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import {App, Octokit} from 'octokit';
|
|
3
|
+
import {config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey} from '#config.js';
|
|
4
|
+
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
5
|
+
import {withInstallationTokenLock} from '#db/installation-token-lock.js';
|
|
6
|
+
import {getGithubInstallationByInstallationId} from '#db/installations.js';
|
|
7
|
+
import {recordInstallationTokenLookup} from '#metrics/index.js';
|
|
8
|
+
import {type GithubInstallationAccessToken, mapGithubError} from './client.js';
|
|
9
|
+
import {
|
|
10
|
+
githubInstallationTokenNamespace,
|
|
11
|
+
TOKEN_REFRESH_MARGIN_MS,
|
|
12
|
+
} from './installation-token-envelope.js';
|
|
13
|
+
import {
|
|
14
|
+
type InstallationTokenCache,
|
|
15
|
+
type InstallationTokenSecretStore,
|
|
16
|
+
SharedInstallationTokenCache,
|
|
17
|
+
type SharedInstallationTokenCacheOptions,
|
|
18
|
+
} from './shared-installation-token-cache.js';
|
|
19
|
+
|
|
20
|
+
export interface GithubInstallationTokenProvider {
|
|
21
|
+
getInstallationAccessToken(installationId: number): Promise<GithubInstallationAccessToken>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GithubInstallationTokenProviderOptions {
|
|
25
|
+
cache?: InstallationTokenCache | undefined;
|
|
26
|
+
getIntegrationConnectionById?: GetIntegrationConnectionByIdFn | undefined;
|
|
27
|
+
secretStore?: InstallationTokenSecretStore | undefined;
|
|
28
|
+
withLock?: SharedInstallationTokenCacheOptions['withLock'] | undefined;
|
|
29
|
+
now?: (() => Date) | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createGithubInstallationTokenProvider(
|
|
33
|
+
options: GithubInstallationTokenProviderOptions = {},
|
|
34
|
+
): GithubInstallationTokenProvider {
|
|
35
|
+
return new OctokitGithubInstallationTokenProvider(createInstallationTokenCache(options));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class OctokitGithubInstallationTokenProvider implements GithubInstallationTokenProvider {
|
|
39
|
+
private app: App | undefined;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly cache: InstallationTokenCache = new InMemoryInstallationTokenCache(),
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
getInstallationAccessToken(installationId: number): Promise<GithubInstallationAccessToken> {
|
|
46
|
+
return this.cache.getOrMint(installationId, () =>
|
|
47
|
+
this.mintInstallationAccessToken(installationId),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async mintInstallationAccessToken(
|
|
52
|
+
installationId: number,
|
|
53
|
+
): Promise<GithubInstallationAccessToken> {
|
|
54
|
+
const response = await mapGithubError(
|
|
55
|
+
() =>
|
|
56
|
+
this.getApp().octokit.rest.apps.createInstallationAccessToken({
|
|
57
|
+
installation_id: installationId,
|
|
58
|
+
}),
|
|
59
|
+
'installation-not-found',
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (typeof response.data.token !== 'string') {
|
|
63
|
+
throw new GithubIntegrationProviderError(
|
|
64
|
+
'malformed-provider-response',
|
|
65
|
+
'GitHub installation access token response did not include a token',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const expiresAt = new Date(response.data.expires_at);
|
|
70
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
71
|
+
throw new GithubIntegrationProviderError(
|
|
72
|
+
'malformed-provider-response',
|
|
73
|
+
'GitHub installation access token response did not include a valid expiry',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
token: response.data.token,
|
|
79
|
+
expiresAt,
|
|
80
|
+
...(response.data.permissions === undefined ? {} : {permissions: response.data.permissions}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private getApp(): App {
|
|
85
|
+
if (!this.app) {
|
|
86
|
+
this.app = new App({
|
|
87
|
+
appId: config.GITHUB_APP_ID,
|
|
88
|
+
privateKey: normalizedGithubPrivateKey(),
|
|
89
|
+
Octokit: Octokit.defaults({
|
|
90
|
+
baseUrl: normalizedGithubApiBaseUrl(),
|
|
91
|
+
throttle: {
|
|
92
|
+
onRateLimit: (
|
|
93
|
+
_retryAfter: number,
|
|
94
|
+
_options: unknown,
|
|
95
|
+
_octokit: unknown,
|
|
96
|
+
retryCount: number,
|
|
97
|
+
) => retryCount === 0,
|
|
98
|
+
onSecondaryRateLimit: (
|
|
99
|
+
_retryAfter: number,
|
|
100
|
+
_options: unknown,
|
|
101
|
+
_octokit: unknown,
|
|
102
|
+
retryCount: number,
|
|
103
|
+
) => retryCount === 0,
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return this.app;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class InMemoryInstallationTokenCache implements InstallationTokenCache {
|
|
113
|
+
private readonly tokens = new Map<number, GithubInstallationAccessToken>();
|
|
114
|
+
private readonly inFlightMints = new Map<number, Promise<GithubInstallationAccessToken>>();
|
|
115
|
+
|
|
116
|
+
constructor(
|
|
117
|
+
private readonly options: {
|
|
118
|
+
refreshMarginMs: number;
|
|
119
|
+
now: () => Date;
|
|
120
|
+
} = {
|
|
121
|
+
refreshMarginMs: TOKEN_REFRESH_MARGIN_MS,
|
|
122
|
+
now: () => new Date(),
|
|
123
|
+
},
|
|
124
|
+
) {}
|
|
125
|
+
|
|
126
|
+
getOrMint(
|
|
127
|
+
installationId: number,
|
|
128
|
+
mint: () => Promise<GithubInstallationAccessToken>,
|
|
129
|
+
): Promise<GithubInstallationAccessToken> {
|
|
130
|
+
const cached = this.tokens.get(installationId);
|
|
131
|
+
if (cached && !this.isInsideRefreshMargin(cached.expiresAt)) {
|
|
132
|
+
recordInstallationTokenLookup('ram-hit');
|
|
133
|
+
return Promise.resolve(cached);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const inFlightMint = this.inFlightMints.get(installationId);
|
|
137
|
+
if (inFlightMint) return inFlightMint;
|
|
138
|
+
|
|
139
|
+
const freshToken = mint()
|
|
140
|
+
.then((token) => {
|
|
141
|
+
this.tokens.set(installationId, token);
|
|
142
|
+
return token;
|
|
143
|
+
})
|
|
144
|
+
.finally(() => {
|
|
145
|
+
this.inFlightMints.delete(installationId);
|
|
146
|
+
});
|
|
147
|
+
this.inFlightMints.set(installationId, freshToken);
|
|
148
|
+
return freshToken;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private isInsideRefreshMargin(expiresAt: Date): boolean {
|
|
152
|
+
return expiresAt.getTime() <= this.options.now().getTime() + this.options.refreshMarginMs;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
class TieredInstallationTokenCache implements InstallationTokenCache {
|
|
157
|
+
constructor(
|
|
158
|
+
private readonly ram: InstallationTokenCache,
|
|
159
|
+
private readonly shared: InstallationTokenCache,
|
|
160
|
+
) {}
|
|
161
|
+
|
|
162
|
+
getOrMint(
|
|
163
|
+
installationId: number,
|
|
164
|
+
mint: () => Promise<GithubInstallationAccessToken>,
|
|
165
|
+
): Promise<GithubInstallationAccessToken> {
|
|
166
|
+
return this.ram.getOrMint(installationId, () => this.shared.getOrMint(installationId, mint));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function createInstallationTokenCache(
|
|
171
|
+
options: GithubInstallationTokenProviderOptions,
|
|
172
|
+
): InstallationTokenCache {
|
|
173
|
+
if (options.cache) return options.cache;
|
|
174
|
+
|
|
175
|
+
const ram = new InMemoryInstallationTokenCache({
|
|
176
|
+
refreshMarginMs: TOKEN_REFRESH_MARGIN_MS,
|
|
177
|
+
now: options.now ?? (() => new Date()),
|
|
178
|
+
});
|
|
179
|
+
if (!options.getIntegrationConnectionById || !options.secretStore) return ram;
|
|
180
|
+
|
|
181
|
+
const shared = new SharedInstallationTokenCache({
|
|
182
|
+
secretStore: options.secretStore,
|
|
183
|
+
withLock: options.withLock ?? withInstallationTokenLock,
|
|
184
|
+
resolveWorkspaceId: createGithubInstallationWorkspaceResolver(
|
|
185
|
+
options.getIntegrationConnectionById,
|
|
186
|
+
),
|
|
187
|
+
now: options.now,
|
|
188
|
+
});
|
|
189
|
+
return new TieredInstallationTokenCache(ram, shared);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function createGithubInstallationWorkspaceResolver(
|
|
193
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn,
|
|
194
|
+
) {
|
|
195
|
+
return async (installationId: number): Promise<string> => {
|
|
196
|
+
const installation = await getGithubInstallationByInstallationId(String(installationId));
|
|
197
|
+
if (!installation) {
|
|
198
|
+
throw new GithubIntegrationProviderError(
|
|
199
|
+
'installation-not-found',
|
|
200
|
+
`GitHub installation not found: ${installationId}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const connection = await getIntegrationConnectionById(installation.connectionId);
|
|
205
|
+
if (!connection) {
|
|
206
|
+
throw new GithubIntegrationProviderError(
|
|
207
|
+
'installation-not-found',
|
|
208
|
+
`GitHub installation connection not found: ${installation.connectionId}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return connection.workspaceId;
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function deleteGithubInstallationTokenSecret(params: {
|
|
216
|
+
workspaceId: string;
|
|
217
|
+
installationId: number;
|
|
218
|
+
deleteSecrets: (params: {workspaceId: string; namespace: string}) => Promise<number>;
|
|
219
|
+
}): Promise<number> {
|
|
220
|
+
return params.deleteSecrets({
|
|
221
|
+
workspaceId: params.workspaceId,
|
|
222
|
+
namespace: githubInstallationTokenNamespace(params.installationId),
|
|
223
|
+
});
|
|
224
|
+
}
|