@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,223 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {
|
|
3
|
+
buildProviderRepositoryId,
|
|
4
|
+
type CheckoutSpec,
|
|
5
|
+
type CreateCheckoutSpecInput,
|
|
6
|
+
type FetchFileInput,
|
|
7
|
+
type FilePage,
|
|
8
|
+
type FileSnapshot,
|
|
9
|
+
type IntegrationConnection,
|
|
10
|
+
type ListFilesInput,
|
|
11
|
+
type ListRepositoriesInput,
|
|
12
|
+
MAX_REPOSITORY_FILE_BYTES,
|
|
13
|
+
parseProviderRepositoryId,
|
|
14
|
+
type RepositoryPage,
|
|
15
|
+
type RepositorySnapshot,
|
|
16
|
+
type RepositoryVisibility,
|
|
17
|
+
type ResolveRepositoryInput,
|
|
18
|
+
type SourceControlProvider,
|
|
19
|
+
} from '@shipfox/api-integration-core-dto';
|
|
20
|
+
import type {GithubApiClient, GithubRepository} from '#api/client.js';
|
|
21
|
+
import {config} from '#config.js';
|
|
22
|
+
import {getGithubInstallationByConnectionId} from '#db/installations.js';
|
|
23
|
+
import {GithubIntegrationProviderError} from './errors.js';
|
|
24
|
+
|
|
25
|
+
type GithubIntegrationConnection = IntegrationConnection<'github'>;
|
|
26
|
+
|
|
27
|
+
const GITHUB_PROVIDER = 'github';
|
|
28
|
+
const GITHUB_APP_BOT_SUFFIX = '[bot]';
|
|
29
|
+
const SEARCH_PAGE_SIZE = 100;
|
|
30
|
+
const SEARCH_MAX_PAGES_PER_REQUEST = 5;
|
|
31
|
+
|
|
32
|
+
export class GithubSourceControlProvider
|
|
33
|
+
implements SourceControlProvider<GithubIntegrationConnection>
|
|
34
|
+
{
|
|
35
|
+
constructor(private readonly github: GithubApiClient) {}
|
|
36
|
+
|
|
37
|
+
async listRepositories(
|
|
38
|
+
input: ListRepositoriesInput<GithubIntegrationConnection>,
|
|
39
|
+
): Promise<RepositoryPage> {
|
|
40
|
+
const installation = await getGithubInstallationByConnectionId(input.connection.id);
|
|
41
|
+
if (!installation) {
|
|
42
|
+
throw new GithubIntegrationProviderError(
|
|
43
|
+
'access-denied',
|
|
44
|
+
'GitHub installation details were not found for the connection',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const installationId = Number.parseInt(installation.installationId, 10);
|
|
49
|
+
const needle = input.search?.trim().toLowerCase();
|
|
50
|
+
|
|
51
|
+
if (!needle) {
|
|
52
|
+
const page = await this.github.listInstallationRepositories({
|
|
53
|
+
installationId,
|
|
54
|
+
limit: input.limit,
|
|
55
|
+
cursor: input.cursor,
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
repositories: page.repositories.map(toRepositorySnapshot),
|
|
59
|
+
nextCursor: page.nextCursor,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const matches: RepositorySnapshot[] = [];
|
|
64
|
+
let cursor = input.cursor;
|
|
65
|
+
let pagesScanned = 0;
|
|
66
|
+
while (matches.length < input.limit && pagesScanned < SEARCH_MAX_PAGES_PER_REQUEST) {
|
|
67
|
+
const page = await this.github.listInstallationRepositories({
|
|
68
|
+
installationId,
|
|
69
|
+
limit: SEARCH_PAGE_SIZE,
|
|
70
|
+
cursor,
|
|
71
|
+
});
|
|
72
|
+
pagesScanned += 1;
|
|
73
|
+
for (const repo of page.repositories) {
|
|
74
|
+
if (repo.fullName.toLowerCase().includes(needle)) {
|
|
75
|
+
matches.push(toRepositorySnapshot(repo));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
cursor = page.nextCursor ?? undefined;
|
|
79
|
+
if (!cursor) break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
repositories: matches.slice(0, input.limit),
|
|
84
|
+
nextCursor: cursor ?? null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async resolveRepository(
|
|
89
|
+
input: ResolveRepositoryInput<GithubIntegrationConnection>,
|
|
90
|
+
): Promise<RepositorySnapshot> {
|
|
91
|
+
const installationId = await this.installationId(input.connection.id);
|
|
92
|
+
const {repositoryId} = parseGithubRepositoryLocator(input.externalRepositoryId);
|
|
93
|
+
const repository = await this.github.getRepository({
|
|
94
|
+
installationId,
|
|
95
|
+
repositoryId,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return toRepositorySnapshot(repository);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async listFiles(input: ListFilesInput<GithubIntegrationConnection>): Promise<FilePage> {
|
|
102
|
+
const installationId = await this.installationId(input.connection.id);
|
|
103
|
+
const {repositoryId} = parseGithubRepositoryLocator(input.externalRepositoryId);
|
|
104
|
+
const page = await this.github.listRepositoryFiles({
|
|
105
|
+
installationId,
|
|
106
|
+
repositoryId,
|
|
107
|
+
ref: input.ref,
|
|
108
|
+
prefix: input.prefix,
|
|
109
|
+
limit: input.limit,
|
|
110
|
+
cursor: input.cursor,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
files: page.files.map((file) => ({path: file.path, type: 'file', size: file.size})),
|
|
115
|
+
nextCursor: page.nextCursor,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async fetchFile(input: FetchFileInput<GithubIntegrationConnection>): Promise<FileSnapshot> {
|
|
120
|
+
const installationId = await this.installationId(input.connection.id);
|
|
121
|
+
const {repositoryId} = parseGithubRepositoryLocator(input.externalRepositoryId);
|
|
122
|
+
const file = await this.github.fetchRepositoryFile({
|
|
123
|
+
installationId,
|
|
124
|
+
repositoryId,
|
|
125
|
+
ref: input.ref,
|
|
126
|
+
path: input.path,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
file.size > MAX_REPOSITORY_FILE_BYTES ||
|
|
131
|
+
Buffer.byteLength(file.content, 'utf8') > MAX_REPOSITORY_FILE_BYTES
|
|
132
|
+
) {
|
|
133
|
+
throw new GithubIntegrationProviderError(
|
|
134
|
+
'content-too-large',
|
|
135
|
+
'GitHub file content is larger than the supported limit',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
path: file.path,
|
|
141
|
+
ref: input.ref,
|
|
142
|
+
content: file.content,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async createCheckoutSpec(
|
|
147
|
+
input: CreateCheckoutSpecInput<GithubIntegrationConnection>,
|
|
148
|
+
): Promise<CheckoutSpec> {
|
|
149
|
+
const installationId = await this.installationId(input.connection.id);
|
|
150
|
+
const {repositoryId} = parseGithubRepositoryLocator(input.externalRepositoryId);
|
|
151
|
+
const repository = await this.github.getRepository({installationId, repositoryId});
|
|
152
|
+
const ref = input.ref?.trim() || repository.defaultBranch;
|
|
153
|
+
const {token, expiresAt} = await this.github.createInstallationAccessToken({
|
|
154
|
+
installationId,
|
|
155
|
+
repositoryId,
|
|
156
|
+
permissions: input.permissions,
|
|
157
|
+
});
|
|
158
|
+
const gitAuthor = githubAppGitAuthor();
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
repositoryUrl: repository.cloneUrl,
|
|
162
|
+
ref,
|
|
163
|
+
credentials: {username: 'x-access-token', token, expiresAt},
|
|
164
|
+
...(gitAuthor ? {gitAuthor} : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async installationId(connectionId: string): Promise<number> {
|
|
169
|
+
const installation = await getGithubInstallationByConnectionId(connectionId);
|
|
170
|
+
if (!installation) {
|
|
171
|
+
throw new GithubIntegrationProviderError(
|
|
172
|
+
'access-denied',
|
|
173
|
+
'GitHub installation details were not found for the connection',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return Number.parseInt(installation.installationId, 10);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function githubAppGitAuthor(): CheckoutSpec['gitAuthor'] {
|
|
182
|
+
const appUsername = config.GITHUB_APP_USERNAME?.trim();
|
|
183
|
+
if (!appUsername) return undefined;
|
|
184
|
+
const name = appUsername.endsWith(GITHUB_APP_BOT_SUFFIX)
|
|
185
|
+
? appUsername
|
|
186
|
+
: `${appUsername}${GITHUB_APP_BOT_SUFFIX}`;
|
|
187
|
+
return {name, email: `${config.GITHUB_APP_ID}+${name}@users.noreply.github.com`};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toRepositorySnapshot(repository: GithubRepository): RepositorySnapshot {
|
|
191
|
+
return {
|
|
192
|
+
externalRepositoryId: buildProviderRepositoryId(GITHUB_PROVIDER, String(repository.id)),
|
|
193
|
+
owner: repository.ownerLogin,
|
|
194
|
+
name: repository.name,
|
|
195
|
+
fullName: repository.fullName,
|
|
196
|
+
defaultBranch: repository.defaultBranch,
|
|
197
|
+
visibility: toRepositoryVisibility(repository),
|
|
198
|
+
cloneUrl: repository.cloneUrl,
|
|
199
|
+
htmlUrl: repository.htmlUrl,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseGithubRepositoryLocator(externalRepositoryId: string): {
|
|
204
|
+
repositoryId: number;
|
|
205
|
+
} {
|
|
206
|
+
const value = parseProviderRepositoryId(externalRepositoryId, GITHUB_PROVIDER);
|
|
207
|
+
const repositoryId = Number.parseInt(value, 10);
|
|
208
|
+
if (!Number.isInteger(repositoryId) || repositoryId <= 0 || String(repositoryId) !== value) {
|
|
209
|
+
throw new GithubIntegrationProviderError(
|
|
210
|
+
'repository-not-found',
|
|
211
|
+
`GitHub repository id ${externalRepositoryId} must follow the form ${GITHUB_PROVIDER}:<numeric-id>`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {repositoryId};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function toRepositoryVisibility(repository: GithubRepository): RepositoryVisibility {
|
|
219
|
+
if (repository.visibility === 'public') return 'public';
|
|
220
|
+
if (repository.visibility === 'private') return 'private';
|
|
221
|
+
if (repository.visibility === 'internal') return 'internal';
|
|
222
|
+
return repository.private ? 'private' : 'public';
|
|
223
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {GithubInstallStateError} from './errors.js';
|
|
2
|
+
import {signGithubInstallState, verifyGithubInstallState} from './state.js';
|
|
3
|
+
|
|
4
|
+
describe('GitHub install state', () => {
|
|
5
|
+
it('verifies a signed state payload', () => {
|
|
6
|
+
const state = signGithubInstallState({
|
|
7
|
+
workspaceId: crypto.randomUUID(),
|
|
8
|
+
userId: crypto.randomUUID(),
|
|
9
|
+
nonce: 'nonce',
|
|
10
|
+
now: new Date('2026-04-30T00:00:00.000Z'),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const result = verifyGithubInstallState(state, new Date('2026-04-30T00:01:00.000Z'));
|
|
14
|
+
|
|
15
|
+
expect(result.workspaceId).toBeTypeOf('string');
|
|
16
|
+
expect(result.userId).toBeTypeOf('string');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects expired state payloads', () => {
|
|
20
|
+
const state = signGithubInstallState({
|
|
21
|
+
workspaceId: crypto.randomUUID(),
|
|
22
|
+
userId: crypto.randomUUID(),
|
|
23
|
+
nonce: 'nonce',
|
|
24
|
+
now: new Date('2026-04-30T00:00:00.000Z'),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const result = () => verifyGithubInstallState(state, new Date('2026-04-30T00:31:00.000Z'));
|
|
28
|
+
|
|
29
|
+
expect(result).toThrow(GithubInstallStateError);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('rejects tampered state payloads', () => {
|
|
33
|
+
const state = signGithubInstallState({
|
|
34
|
+
workspaceId: crypto.randomUUID(),
|
|
35
|
+
userId: crypto.randomUUID(),
|
|
36
|
+
nonce: 'nonce',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const result = () => verifyGithubInstallState(`${state}tampered`);
|
|
40
|
+
|
|
41
|
+
expect(result).toThrow(GithubInstallStateError);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {createHmac, randomUUID, timingSafeEqual} from 'node:crypto';
|
|
2
|
+
import {config} from '#config.js';
|
|
3
|
+
import {GithubInstallStateError} from './errors.js';
|
|
4
|
+
|
|
5
|
+
const STATE_TTL_SECONDS = 30 * 60;
|
|
6
|
+
|
|
7
|
+
interface GithubInstallStatePayload {
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
nonce: string;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GithubInstallStateClaims {
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function signGithubInstallState(params: {
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
userId: string;
|
|
22
|
+
nonce?: string | undefined;
|
|
23
|
+
now?: Date | undefined;
|
|
24
|
+
}): string {
|
|
25
|
+
const now = params.now ?? new Date();
|
|
26
|
+
const payload: GithubInstallStatePayload = {
|
|
27
|
+
workspaceId: params.workspaceId,
|
|
28
|
+
userId: params.userId,
|
|
29
|
+
nonce: params.nonce ?? randomUUID(),
|
|
30
|
+
expiresAt: Math.floor(now.getTime() / 1000) + STATE_TTL_SECONDS,
|
|
31
|
+
};
|
|
32
|
+
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
33
|
+
return `${encodedPayload}.${sign(encodedPayload)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function verifyGithubInstallState(
|
|
37
|
+
state: string,
|
|
38
|
+
now: Date = new Date(),
|
|
39
|
+
): GithubInstallStateClaims {
|
|
40
|
+
const [encodedPayload, signature, extra] = state.split('.');
|
|
41
|
+
if (!encodedPayload || !signature || extra !== undefined) {
|
|
42
|
+
throw new GithubInstallStateError('Invalid GitHub install state');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!constantTimeEqual(signature, sign(encodedPayload))) {
|
|
46
|
+
throw new GithubInstallStateError('Invalid GitHub install state signature');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const payload = parsePayload(encodedPayload);
|
|
50
|
+
if (payload.expiresAt < Math.floor(now.getTime() / 1000)) {
|
|
51
|
+
throw new GithubInstallStateError('Expired GitHub install state');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {workspaceId: payload.workspaceId, userId: payload.userId};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sign(encodedPayload: string): string {
|
|
58
|
+
return createHmac('sha256', config.GITHUB_INSTALL_STATE_SECRET)
|
|
59
|
+
.update(encodedPayload)
|
|
60
|
+
.digest('base64url');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
64
|
+
const left = Buffer.from(a);
|
|
65
|
+
const right = Buffer.from(b);
|
|
66
|
+
if (left.length !== right.length) return false;
|
|
67
|
+
return timingSafeEqual(left, right);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parsePayload(encodedPayload: string): GithubInstallStatePayload {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
|
|
73
|
+
if (
|
|
74
|
+
typeof parsed.workspaceId !== 'string' ||
|
|
75
|
+
typeof parsed.userId !== 'string' ||
|
|
76
|
+
typeof parsed.nonce !== 'string' ||
|
|
77
|
+
typeof parsed.expiresAt !== 'number'
|
|
78
|
+
) {
|
|
79
|
+
throw new Error('Invalid payload shape');
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
} catch (_error) {
|
|
83
|
+
throw new GithubInstallStateError('Invalid GitHub install state payload');
|
|
84
|
+
}
|
|
85
|
+
}
|