@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,36 @@
|
|
|
1
|
+
import { type IntegrationProviderErrorReason } from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import { GithubIntegrationProviderError } from '#core/errors.js';
|
|
3
|
+
export declare const TOKEN_REFRESH_MARGIN_MS: number;
|
|
4
|
+
export declare const TOKEN_VALIDITY_BUFFER_MS: number;
|
|
5
|
+
export declare const TRANSIENT_BACKOFF_MIN_MS: number;
|
|
6
|
+
export declare const TRANSIENT_BACKOFF_MAX_MS: number;
|
|
7
|
+
export declare const TERMINAL_BACKOFF_MS: number;
|
|
8
|
+
export interface InstallationTokenEnvelope {
|
|
9
|
+
token?: string | undefined;
|
|
10
|
+
expiresAt?: Date | undefined;
|
|
11
|
+
permissions?: Record<string, 'read' | 'write' | 'admin'> | undefined;
|
|
12
|
+
backoffUntil?: Date | undefined;
|
|
13
|
+
backoffReason?: IntegrationProviderErrorReason | undefined;
|
|
14
|
+
}
|
|
15
|
+
export type MintErrorClass = 'transient' | 'terminal';
|
|
16
|
+
export interface ClassifiedMintError {
|
|
17
|
+
class: MintErrorClass;
|
|
18
|
+
reason: IntegrationProviderErrorReason;
|
|
19
|
+
retryAfterSeconds?: number | undefined;
|
|
20
|
+
}
|
|
21
|
+
export declare function githubInstallationTokenNamespace(installationId: number): string;
|
|
22
|
+
export declare function encodeInstallationTokenEnvelope(envelope: InstallationTokenEnvelope): string;
|
|
23
|
+
export declare function parseInstallationTokenEnvelope(raw: string): InstallationTokenEnvelope | undefined;
|
|
24
|
+
export declare function usable(envelope: InstallationTokenEnvelope | undefined, now: Date): envelope is InstallationTokenEnvelope & {
|
|
25
|
+
token: string;
|
|
26
|
+
expiresAt: Date;
|
|
27
|
+
};
|
|
28
|
+
export declare function needsRefresh(expiresAt: Date, now: Date): boolean;
|
|
29
|
+
export declare function stillValid(expiresAt: Date | undefined, now: Date): boolean;
|
|
30
|
+
export declare function backoffActive(envelope: InstallationTokenEnvelope | undefined, now: Date): boolean;
|
|
31
|
+
export declare function classifyMintError(error: unknown): ClassifiedMintError;
|
|
32
|
+
export declare function mintErrorClassForReason(reason: IntegrationProviderErrorReason): MintErrorClass;
|
|
33
|
+
export declare function backoffMs(classified: ClassifiedMintError): number;
|
|
34
|
+
export declare function providerErrorFromBackoff(reason: IntegrationProviderErrorReason, retryAfterMs: number): GithubIntegrationProviderError;
|
|
35
|
+
export declare function toProviderError(error: unknown): GithubIntegrationProviderError;
|
|
36
|
+
//# sourceMappingURL=installation-token-envelope.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"installation-token-envelope.d.ts","sourceRoot":"","sources":["../../src/api/installation-token-envelope.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,8BAA8B,EACpC,MAAM,mCAAmC,CAAC;AAE3C,OAAO,EAAC,8BAA8B,EAAC,MAAM,iBAAiB,CAAC;AAE/D,eAAO,MAAM,uBAAuB,QAAgB,CAAC;AACrD,eAAO,MAAM,wBAAwB,QAAY,CAAC;AAClD,eAAO,MAAM,wBAAwB,QAAY,CAAC;AAClD,eAAO,MAAM,wBAAwB,QAAgB,CAAC;AACtD,eAAO,MAAM,mBAAmB,QAAiB,CAAC;AAqClD,MAAM,WAAW,yBAAyB;IACxC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,SAAS,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,GAAG,SAAS,CAAC;IACrE,YAAY,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;IAChC,aAAa,CAAC,EAAE,8BAA8B,GAAG,SAAS,CAAC;CAC5D;AAED,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG,UAAU,CAAC;AAEtD,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,8BAA8B,CAAC;IACvC,iBAAiB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC;AAED,wBAAgB,gCAAgC,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED,wBAAgB,+BAA+B,CAAC,QAAQ,EAAE,yBAAyB,GAAG,MAAM,CAU3F;AAED,wBAAgB,8BAA8B,CAAC,GAAG,EAAE,MAAM,GAAG,yBAAyB,GAAG,SAAS,CAkBjG;AAED,wBAAgB,MAAM,CACpB,QAAQ,EAAE,yBAAyB,GAAG,SAAS,EAC/C,GAAG,EAAE,IAAI,GACR,QAAQ,IAAI,yBAAyB,GAAG;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAC,CAM1E;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAEhE;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,IAAI,GAAG,SAAS,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAE1E;AAED,wBAAgB,aAAa,CAAC,QAAQ,EAAE,yBAAyB,GAAG,SAAS,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAMjG;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,mBAAmB,CAUrE;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,8BAA8B,GAAG,cAAc,CAE9F;AAED,wBAAgB,SAAS,CAAC,UAAU,EAAE,mBAAmB,GAAG,MAAM,CAKjE;AAED,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,8BAA8B,EACtC,YAAY,EAAE,MAAM,GACnB,8BAA8B,CAMhC;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,8BAA8B,CAS9E"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { IntegrationProviderError } from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { GithubIntegrationProviderError } from '#core/errors.js';
|
|
4
|
+
export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
5
|
+
export const TOKEN_VALIDITY_BUFFER_MS = 60 * 1000;
|
|
6
|
+
export const TRANSIENT_BACKOFF_MIN_MS = 30 * 1000;
|
|
7
|
+
export const TRANSIENT_BACKOFF_MAX_MS = 5 * 60 * 1000;
|
|
8
|
+
export const TERMINAL_BACKOFF_MS = 15 * 60 * 1000;
|
|
9
|
+
const providerErrorReasons = [
|
|
10
|
+
'repository-not-found',
|
|
11
|
+
'installation-not-found',
|
|
12
|
+
'file-not-found',
|
|
13
|
+
'access-denied',
|
|
14
|
+
'rate-limited',
|
|
15
|
+
'timeout',
|
|
16
|
+
'provider-unavailable',
|
|
17
|
+
'malformed-provider-response',
|
|
18
|
+
'content-too-large',
|
|
19
|
+
'too-many-files'
|
|
20
|
+
];
|
|
21
|
+
const providerErrorReasonSchema = z.enum(providerErrorReasons);
|
|
22
|
+
const terminalMintErrorReasons = new Set([
|
|
23
|
+
'access-denied',
|
|
24
|
+
'installation-not-found',
|
|
25
|
+
'malformed-provider-response'
|
|
26
|
+
]);
|
|
27
|
+
const providerErrorReasonSchemaCoversUnion = {};
|
|
28
|
+
void providerErrorReasonSchemaCoversUnion;
|
|
29
|
+
const installationTokenEnvelopeSchema = z.object({
|
|
30
|
+
token: z.string().min(1).optional(),
|
|
31
|
+
expiresAt: z.string().datetime().optional(),
|
|
32
|
+
permissions: z.record(z.string(), z.enum([
|
|
33
|
+
'read',
|
|
34
|
+
'write',
|
|
35
|
+
'admin'
|
|
36
|
+
])).optional(),
|
|
37
|
+
backoffUntil: z.string().datetime().optional(),
|
|
38
|
+
backoffReason: providerErrorReasonSchema.optional()
|
|
39
|
+
});
|
|
40
|
+
export function githubInstallationTokenNamespace(installationId) {
|
|
41
|
+
return `system/github/installation-token/${installationId}`;
|
|
42
|
+
}
|
|
43
|
+
export function encodeInstallationTokenEnvelope(envelope) {
|
|
44
|
+
return JSON.stringify({
|
|
45
|
+
...envelope.token !== undefined && {
|
|
46
|
+
token: envelope.token
|
|
47
|
+
},
|
|
48
|
+
...envelope.expiresAt !== undefined && {
|
|
49
|
+
expiresAt: envelope.expiresAt.toISOString()
|
|
50
|
+
},
|
|
51
|
+
...envelope.permissions !== undefined && {
|
|
52
|
+
permissions: envelope.permissions
|
|
53
|
+
},
|
|
54
|
+
...envelope.backoffUntil !== undefined && {
|
|
55
|
+
backoffUntil: envelope.backoffUntil.toISOString()
|
|
56
|
+
},
|
|
57
|
+
...envelope.backoffReason !== undefined && {
|
|
58
|
+
backoffReason: envelope.backoffReason
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function parseInstallationTokenEnvelope(raw) {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const result = installationTokenEnvelopeSchema.safeParse(parsed);
|
|
70
|
+
if (!result.success) return undefined;
|
|
71
|
+
return {
|
|
72
|
+
token: result.data.token,
|
|
73
|
+
expiresAt: result.data.expiresAt ? new Date(result.data.expiresAt) : undefined,
|
|
74
|
+
permissions: result.data.permissions,
|
|
75
|
+
backoffUntil: result.data.backoffUntil ? new Date(result.data.backoffUntil) : undefined,
|
|
76
|
+
backoffReason: result.data.backoffReason
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function usable(envelope, now) {
|
|
80
|
+
return envelope?.token !== undefined && envelope.expiresAt !== undefined && !needsRefresh(envelope.expiresAt, now);
|
|
81
|
+
}
|
|
82
|
+
export function needsRefresh(expiresAt, now) {
|
|
83
|
+
return expiresAt.getTime() <= now.getTime() + TOKEN_REFRESH_MARGIN_MS;
|
|
84
|
+
}
|
|
85
|
+
export function stillValid(expiresAt, now) {
|
|
86
|
+
return expiresAt !== undefined && expiresAt.getTime() > now.getTime() + TOKEN_VALIDITY_BUFFER_MS;
|
|
87
|
+
}
|
|
88
|
+
export function backoffActive(envelope, now) {
|
|
89
|
+
return envelope?.backoffUntil !== undefined && envelope.backoffReason !== undefined && envelope.backoffUntil.getTime() > now.getTime();
|
|
90
|
+
}
|
|
91
|
+
export function classifyMintError(error) {
|
|
92
|
+
if (error instanceof IntegrationProviderError) {
|
|
93
|
+
return {
|
|
94
|
+
reason: error.reason,
|
|
95
|
+
retryAfterSeconds: error.retryAfterSeconds,
|
|
96
|
+
class: mintErrorClassForReason(error.reason)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
reason: 'provider-unavailable',
|
|
101
|
+
class: 'transient'
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function mintErrorClassForReason(reason) {
|
|
105
|
+
return terminalMintErrorReasons.has(reason) ? 'terminal' : 'transient';
|
|
106
|
+
}
|
|
107
|
+
export function backoffMs(classified) {
|
|
108
|
+
if (classified.class === 'terminal') return TERMINAL_BACKOFF_MS;
|
|
109
|
+
const retryAfterMs = (classified.retryAfterSeconds ?? 0) * 1000;
|
|
110
|
+
return Math.min(TRANSIENT_BACKOFF_MAX_MS, Math.max(TRANSIENT_BACKOFF_MIN_MS, retryAfterMs));
|
|
111
|
+
}
|
|
112
|
+
export function providerErrorFromBackoff(reason, retryAfterMs) {
|
|
113
|
+
return new GithubIntegrationProviderError(reason, `GitHub installation token mint is backed off after ${reason}`, Math.max(1, Math.ceil(retryAfterMs / 1000)));
|
|
114
|
+
}
|
|
115
|
+
export function toProviderError(error) {
|
|
116
|
+
if (error instanceof GithubIntegrationProviderError) return error;
|
|
117
|
+
if (error instanceof IntegrationProviderError) {
|
|
118
|
+
return new GithubIntegrationProviderError(error.reason, error.message, error.retryAfterSeconds);
|
|
119
|
+
}
|
|
120
|
+
return new GithubIntegrationProviderError('provider-unavailable', error instanceof Error ? error.message : 'GitHub installation token mint failed');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
//# sourceMappingURL=installation-token-envelope.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/api/installation-token-envelope.ts"],"sourcesContent":["import {\n IntegrationProviderError,\n type IntegrationProviderErrorReason,\n} from '@shipfox/api-integration-core-dto';\nimport {z} from 'zod';\nimport {GithubIntegrationProviderError} from '#core/errors.js';\n\nexport const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;\nexport const TOKEN_VALIDITY_BUFFER_MS = 60 * 1000;\nexport const TRANSIENT_BACKOFF_MIN_MS = 30 * 1000;\nexport const TRANSIENT_BACKOFF_MAX_MS = 5 * 60 * 1000;\nexport const TERMINAL_BACKOFF_MS = 15 * 60 * 1000;\n\nconst providerErrorReasons = [\n 'repository-not-found',\n 'installation-not-found',\n 'file-not-found',\n 'access-denied',\n 'rate-limited',\n 'timeout',\n 'provider-unavailable',\n 'malformed-provider-response',\n 'content-too-large',\n 'too-many-files',\n] as const satisfies readonly IntegrationProviderErrorReason[];\n\nconst providerErrorReasonSchema = z.enum(providerErrorReasons);\nconst terminalMintErrorReasons = new Set<IntegrationProviderErrorReason>([\n 'access-denied',\n 'installation-not-found',\n 'malformed-provider-response',\n]);\n\ntype MissingProviderErrorReason = Exclude<\n IntegrationProviderErrorReason,\n (typeof providerErrorReasons)[number]\n>;\nconst providerErrorReasonSchemaCoversUnion: Record<MissingProviderErrorReason, never> = {};\nvoid providerErrorReasonSchemaCoversUnion;\n\nconst installationTokenEnvelopeSchema = z.object({\n token: z.string().min(1).optional(),\n expiresAt: z.string().datetime().optional(),\n permissions: z.record(z.string(), z.enum(['read', 'write', 'admin'])).optional(),\n backoffUntil: z.string().datetime().optional(),\n backoffReason: providerErrorReasonSchema.optional(),\n});\n\nexport interface InstallationTokenEnvelope {\n token?: string | undefined;\n expiresAt?: Date | undefined;\n permissions?: Record<string, 'read' | 'write' | 'admin'> | undefined;\n backoffUntil?: Date | undefined;\n backoffReason?: IntegrationProviderErrorReason | undefined;\n}\n\nexport type MintErrorClass = 'transient' | 'terminal';\n\nexport interface ClassifiedMintError {\n class: MintErrorClass;\n reason: IntegrationProviderErrorReason;\n retryAfterSeconds?: number | undefined;\n}\n\nexport function githubInstallationTokenNamespace(installationId: number): string {\n return `system/github/installation-token/${installationId}`;\n}\n\nexport function encodeInstallationTokenEnvelope(envelope: InstallationTokenEnvelope): string {\n return JSON.stringify({\n ...(envelope.token !== undefined && {token: envelope.token}),\n ...(envelope.expiresAt !== undefined && {expiresAt: envelope.expiresAt.toISOString()}),\n ...(envelope.permissions !== undefined && {permissions: envelope.permissions}),\n ...(envelope.backoffUntil !== undefined && {\n backoffUntil: envelope.backoffUntil.toISOString(),\n }),\n ...(envelope.backoffReason !== undefined && {backoffReason: envelope.backoffReason}),\n });\n}\n\nexport function parseInstallationTokenEnvelope(raw: string): InstallationTokenEnvelope | undefined {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return undefined;\n }\n\n const result = installationTokenEnvelopeSchema.safeParse(parsed);\n if (!result.success) return undefined;\n\n return {\n token: result.data.token,\n expiresAt: result.data.expiresAt ? new Date(result.data.expiresAt) : undefined,\n permissions: result.data.permissions,\n backoffUntil: result.data.backoffUntil ? new Date(result.data.backoffUntil) : undefined,\n backoffReason: result.data.backoffReason,\n };\n}\n\nexport function usable(\n envelope: InstallationTokenEnvelope | undefined,\n now: Date,\n): envelope is InstallationTokenEnvelope & {token: string; expiresAt: Date} {\n return (\n envelope?.token !== undefined &&\n envelope.expiresAt !== undefined &&\n !needsRefresh(envelope.expiresAt, now)\n );\n}\n\nexport function needsRefresh(expiresAt: Date, now: Date): boolean {\n return expiresAt.getTime() <= now.getTime() + TOKEN_REFRESH_MARGIN_MS;\n}\n\nexport function stillValid(expiresAt: Date | undefined, now: Date): boolean {\n return expiresAt !== undefined && expiresAt.getTime() > now.getTime() + TOKEN_VALIDITY_BUFFER_MS;\n}\n\nexport function backoffActive(envelope: InstallationTokenEnvelope | undefined, now: Date): boolean {\n return (\n envelope?.backoffUntil !== undefined &&\n envelope.backoffReason !== undefined &&\n envelope.backoffUntil.getTime() > now.getTime()\n );\n}\n\nexport function classifyMintError(error: unknown): ClassifiedMintError {\n if (error instanceof IntegrationProviderError) {\n return {\n reason: error.reason,\n retryAfterSeconds: error.retryAfterSeconds,\n class: mintErrorClassForReason(error.reason),\n };\n }\n\n return {reason: 'provider-unavailable', class: 'transient'};\n}\n\nexport function mintErrorClassForReason(reason: IntegrationProviderErrorReason): MintErrorClass {\n return terminalMintErrorReasons.has(reason) ? 'terminal' : 'transient';\n}\n\nexport function backoffMs(classified: ClassifiedMintError): number {\n if (classified.class === 'terminal') return TERMINAL_BACKOFF_MS;\n\n const retryAfterMs = (classified.retryAfterSeconds ?? 0) * 1000;\n return Math.min(TRANSIENT_BACKOFF_MAX_MS, Math.max(TRANSIENT_BACKOFF_MIN_MS, retryAfterMs));\n}\n\nexport function providerErrorFromBackoff(\n reason: IntegrationProviderErrorReason,\n retryAfterMs: number,\n): GithubIntegrationProviderError {\n return new GithubIntegrationProviderError(\n reason,\n `GitHub installation token mint is backed off after ${reason}`,\n Math.max(1, Math.ceil(retryAfterMs / 1000)),\n );\n}\n\nexport function toProviderError(error: unknown): GithubIntegrationProviderError {\n if (error instanceof GithubIntegrationProviderError) return error;\n if (error instanceof IntegrationProviderError) {\n return new GithubIntegrationProviderError(error.reason, error.message, error.retryAfterSeconds);\n }\n return new GithubIntegrationProviderError(\n 'provider-unavailable',\n error instanceof Error ? error.message : 'GitHub installation token mint failed',\n );\n}\n"],"names":["IntegrationProviderError","z","GithubIntegrationProviderError","TOKEN_REFRESH_MARGIN_MS","TOKEN_VALIDITY_BUFFER_MS","TRANSIENT_BACKOFF_MIN_MS","TRANSIENT_BACKOFF_MAX_MS","TERMINAL_BACKOFF_MS","providerErrorReasons","providerErrorReasonSchema","enum","terminalMintErrorReasons","Set","providerErrorReasonSchemaCoversUnion","installationTokenEnvelopeSchema","object","token","string","min","optional","expiresAt","datetime","permissions","record","backoffUntil","backoffReason","githubInstallationTokenNamespace","installationId","encodeInstallationTokenEnvelope","envelope","JSON","stringify","undefined","toISOString","parseInstallationTokenEnvelope","raw","parsed","parse","result","safeParse","success","data","Date","usable","now","needsRefresh","getTime","stillValid","backoffActive","classifyMintError","error","reason","retryAfterSeconds","class","mintErrorClassForReason","has","backoffMs","classified","retryAfterMs","Math","max","providerErrorFromBackoff","ceil","toProviderError","message","Error"],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { GetIntegrationConnectionByIdFn } from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import { type GithubInstallationAccessToken } from './client.js';
|
|
3
|
+
import { type InstallationTokenCache, type InstallationTokenSecretStore, type SharedInstallationTokenCacheOptions } from './shared-installation-token-cache.js';
|
|
4
|
+
export interface GithubInstallationTokenProvider {
|
|
5
|
+
getInstallationAccessToken(installationId: number): Promise<GithubInstallationAccessToken>;
|
|
6
|
+
}
|
|
7
|
+
export interface GithubInstallationTokenProviderOptions {
|
|
8
|
+
cache?: InstallationTokenCache | undefined;
|
|
9
|
+
getIntegrationConnectionById?: GetIntegrationConnectionByIdFn | undefined;
|
|
10
|
+
secretStore?: InstallationTokenSecretStore | undefined;
|
|
11
|
+
withLock?: SharedInstallationTokenCacheOptions['withLock'] | undefined;
|
|
12
|
+
now?: (() => Date) | undefined;
|
|
13
|
+
}
|
|
14
|
+
export declare function createGithubInstallationTokenProvider(options?: GithubInstallationTokenProviderOptions): GithubInstallationTokenProvider;
|
|
15
|
+
export declare function deleteGithubInstallationTokenSecret(params: {
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
installationId: number;
|
|
18
|
+
deleteSecrets: (params: {
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
namespace: string;
|
|
21
|
+
}) => Promise<number>;
|
|
22
|
+
}): Promise<number>;
|
|
23
|
+
//# sourceMappingURL=installation-token-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"installation-token-provider.d.ts","sourceRoot":"","sources":["../../src/api/installation-token-provider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,8BAA8B,EAAC,MAAM,mCAAmC,CAAC;AAOtF,OAAO,EAAC,KAAK,6BAA6B,EAAiB,MAAM,aAAa,CAAC;AAK/E,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,4BAA4B,EAEjC,KAAK,mCAAmC,EACzC,MAAM,sCAAsC,CAAC;AAE9C,MAAM,WAAW,+BAA+B;IAC9C,0BAA0B,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,6BAA6B,CAAC,CAAC;CAC5F;AAED,MAAM,WAAW,sCAAsC;IACrD,KAAK,CAAC,EAAE,sBAAsB,GAAG,SAAS,CAAC;IAC3C,4BAA4B,CAAC,EAAE,8BAA8B,GAAG,SAAS,CAAC;IAC1E,WAAW,CAAC,EAAE,4BAA4B,GAAG,SAAS,CAAC;IACvD,QAAQ,CAAC,EAAE,mCAAmC,CAAC,UAAU,CAAC,GAAG,SAAS,CAAC;IACvE,GAAG,CAAC,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC;CAChC;AAED,wBAAgB,qCAAqC,CACnD,OAAO,GAAE,sCAA2C,GACnD,+BAA+B,CAEjC;AAmLD,wBAAgB,mCAAmC,CAAC,MAAM,EAAE;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACtF,GAAG,OAAO,CAAC,MAAM,CAAC,CAKlB"}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { App, Octokit } from 'octokit';
|
|
2
|
+
import { config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey } from '#config.js';
|
|
3
|
+
import { GithubIntegrationProviderError } from '#core/errors.js';
|
|
4
|
+
import { withInstallationTokenLock } from '#db/installation-token-lock.js';
|
|
5
|
+
import { getGithubInstallationByInstallationId } from '#db/installations.js';
|
|
6
|
+
import { recordInstallationTokenLookup } from '#metrics/index.js';
|
|
7
|
+
import { mapGithubError } from './client.js';
|
|
8
|
+
import { githubInstallationTokenNamespace, TOKEN_REFRESH_MARGIN_MS } from './installation-token-envelope.js';
|
|
9
|
+
import { SharedInstallationTokenCache } from './shared-installation-token-cache.js';
|
|
10
|
+
export function createGithubInstallationTokenProvider(options = {}) {
|
|
11
|
+
return new OctokitGithubInstallationTokenProvider(createInstallationTokenCache(options));
|
|
12
|
+
}
|
|
13
|
+
let OctokitGithubInstallationTokenProvider = class OctokitGithubInstallationTokenProvider {
|
|
14
|
+
constructor(cache = new InMemoryInstallationTokenCache()){
|
|
15
|
+
this.cache = cache;
|
|
16
|
+
}
|
|
17
|
+
getInstallationAccessToken(installationId) {
|
|
18
|
+
return this.cache.getOrMint(installationId, ()=>this.mintInstallationAccessToken(installationId));
|
|
19
|
+
}
|
|
20
|
+
async mintInstallationAccessToken(installationId) {
|
|
21
|
+
const response = await mapGithubError(()=>this.getApp().octokit.rest.apps.createInstallationAccessToken({
|
|
22
|
+
installation_id: installationId
|
|
23
|
+
}), 'installation-not-found');
|
|
24
|
+
if (typeof response.data.token !== 'string') {
|
|
25
|
+
throw new GithubIntegrationProviderError('malformed-provider-response', 'GitHub installation access token response did not include a token');
|
|
26
|
+
}
|
|
27
|
+
const expiresAt = new Date(response.data.expires_at);
|
|
28
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
29
|
+
throw new GithubIntegrationProviderError('malformed-provider-response', 'GitHub installation access token response did not include a valid expiry');
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
token: response.data.token,
|
|
33
|
+
expiresAt,
|
|
34
|
+
...response.data.permissions === undefined ? {} : {
|
|
35
|
+
permissions: response.data.permissions
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
getApp() {
|
|
40
|
+
if (!this.app) {
|
|
41
|
+
this.app = new App({
|
|
42
|
+
appId: config.GITHUB_APP_ID,
|
|
43
|
+
privateKey: normalizedGithubPrivateKey(),
|
|
44
|
+
Octokit: Octokit.defaults({
|
|
45
|
+
baseUrl: normalizedGithubApiBaseUrl(),
|
|
46
|
+
throttle: {
|
|
47
|
+
onRateLimit: (_retryAfter, _options, _octokit, retryCount)=>retryCount === 0,
|
|
48
|
+
onSecondaryRateLimit: (_retryAfter, _options, _octokit, retryCount)=>retryCount === 0
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return this.app;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
let InMemoryInstallationTokenCache = class InMemoryInstallationTokenCache {
|
|
57
|
+
constructor(options = {
|
|
58
|
+
refreshMarginMs: TOKEN_REFRESH_MARGIN_MS,
|
|
59
|
+
now: ()=>new Date()
|
|
60
|
+
}){
|
|
61
|
+
this.options = options;
|
|
62
|
+
this.tokens = new Map();
|
|
63
|
+
this.inFlightMints = new Map();
|
|
64
|
+
}
|
|
65
|
+
getOrMint(installationId, mint) {
|
|
66
|
+
const cached = this.tokens.get(installationId);
|
|
67
|
+
if (cached && !this.isInsideRefreshMargin(cached.expiresAt)) {
|
|
68
|
+
recordInstallationTokenLookup('ram-hit');
|
|
69
|
+
return Promise.resolve(cached);
|
|
70
|
+
}
|
|
71
|
+
const inFlightMint = this.inFlightMints.get(installationId);
|
|
72
|
+
if (inFlightMint) return inFlightMint;
|
|
73
|
+
const freshToken = mint().then((token)=>{
|
|
74
|
+
this.tokens.set(installationId, token);
|
|
75
|
+
return token;
|
|
76
|
+
}).finally(()=>{
|
|
77
|
+
this.inFlightMints.delete(installationId);
|
|
78
|
+
});
|
|
79
|
+
this.inFlightMints.set(installationId, freshToken);
|
|
80
|
+
return freshToken;
|
|
81
|
+
}
|
|
82
|
+
isInsideRefreshMargin(expiresAt) {
|
|
83
|
+
return expiresAt.getTime() <= this.options.now().getTime() + this.options.refreshMarginMs;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
let TieredInstallationTokenCache = class TieredInstallationTokenCache {
|
|
87
|
+
constructor(ram, shared){
|
|
88
|
+
this.ram = ram;
|
|
89
|
+
this.shared = shared;
|
|
90
|
+
}
|
|
91
|
+
getOrMint(installationId, mint) {
|
|
92
|
+
return this.ram.getOrMint(installationId, ()=>this.shared.getOrMint(installationId, mint));
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
function createInstallationTokenCache(options) {
|
|
96
|
+
if (options.cache) return options.cache;
|
|
97
|
+
const ram = new InMemoryInstallationTokenCache({
|
|
98
|
+
refreshMarginMs: TOKEN_REFRESH_MARGIN_MS,
|
|
99
|
+
now: options.now ?? (()=>new Date())
|
|
100
|
+
});
|
|
101
|
+
if (!options.getIntegrationConnectionById || !options.secretStore) return ram;
|
|
102
|
+
const shared = new SharedInstallationTokenCache({
|
|
103
|
+
secretStore: options.secretStore,
|
|
104
|
+
withLock: options.withLock ?? withInstallationTokenLock,
|
|
105
|
+
resolveWorkspaceId: createGithubInstallationWorkspaceResolver(options.getIntegrationConnectionById),
|
|
106
|
+
now: options.now
|
|
107
|
+
});
|
|
108
|
+
return new TieredInstallationTokenCache(ram, shared);
|
|
109
|
+
}
|
|
110
|
+
function createGithubInstallationWorkspaceResolver(getIntegrationConnectionById) {
|
|
111
|
+
return async (installationId)=>{
|
|
112
|
+
const installation = await getGithubInstallationByInstallationId(String(installationId));
|
|
113
|
+
if (!installation) {
|
|
114
|
+
throw new GithubIntegrationProviderError('installation-not-found', `GitHub installation not found: ${installationId}`);
|
|
115
|
+
}
|
|
116
|
+
const connection = await getIntegrationConnectionById(installation.connectionId);
|
|
117
|
+
if (!connection) {
|
|
118
|
+
throw new GithubIntegrationProviderError('installation-not-found', `GitHub installation connection not found: ${installation.connectionId}`);
|
|
119
|
+
}
|
|
120
|
+
return connection.workspaceId;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function deleteGithubInstallationTokenSecret(params) {
|
|
124
|
+
return params.deleteSecrets({
|
|
125
|
+
workspaceId: params.workspaceId,
|
|
126
|
+
namespace: githubInstallationTokenNamespace(params.installationId)
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
//# sourceMappingURL=installation-token-provider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/api/installation-token-provider.ts"],"sourcesContent":["import type {GetIntegrationConnectionByIdFn} from '@shipfox/api-integration-core-dto';\nimport {App, Octokit} from 'octokit';\nimport {config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey} from '#config.js';\nimport {GithubIntegrationProviderError} from '#core/errors.js';\nimport {withInstallationTokenLock} from '#db/installation-token-lock.js';\nimport {getGithubInstallationByInstallationId} from '#db/installations.js';\nimport {recordInstallationTokenLookup} from '#metrics/index.js';\nimport {type GithubInstallationAccessToken, mapGithubError} from './client.js';\nimport {\n githubInstallationTokenNamespace,\n TOKEN_REFRESH_MARGIN_MS,\n} from './installation-token-envelope.js';\nimport {\n type InstallationTokenCache,\n type InstallationTokenSecretStore,\n SharedInstallationTokenCache,\n type SharedInstallationTokenCacheOptions,\n} from './shared-installation-token-cache.js';\n\nexport interface GithubInstallationTokenProvider {\n getInstallationAccessToken(installationId: number): Promise<GithubInstallationAccessToken>;\n}\n\nexport interface GithubInstallationTokenProviderOptions {\n cache?: InstallationTokenCache | undefined;\n getIntegrationConnectionById?: GetIntegrationConnectionByIdFn | undefined;\n secretStore?: InstallationTokenSecretStore | undefined;\n withLock?: SharedInstallationTokenCacheOptions['withLock'] | undefined;\n now?: (() => Date) | undefined;\n}\n\nexport function createGithubInstallationTokenProvider(\n options: GithubInstallationTokenProviderOptions = {},\n): GithubInstallationTokenProvider {\n return new OctokitGithubInstallationTokenProvider(createInstallationTokenCache(options));\n}\n\nclass OctokitGithubInstallationTokenProvider implements GithubInstallationTokenProvider {\n private app: App | undefined;\n\n constructor(\n private readonly cache: InstallationTokenCache = new InMemoryInstallationTokenCache(),\n ) {}\n\n getInstallationAccessToken(installationId: number): Promise<GithubInstallationAccessToken> {\n return this.cache.getOrMint(installationId, () =>\n this.mintInstallationAccessToken(installationId),\n );\n }\n\n private async mintInstallationAccessToken(\n installationId: number,\n ): Promise<GithubInstallationAccessToken> {\n const response = await mapGithubError(\n () =>\n this.getApp().octokit.rest.apps.createInstallationAccessToken({\n installation_id: installationId,\n }),\n 'installation-not-found',\n );\n\n if (typeof response.data.token !== 'string') {\n throw new GithubIntegrationProviderError(\n 'malformed-provider-response',\n 'GitHub installation access token response did not include a token',\n );\n }\n\n const expiresAt = new Date(response.data.expires_at);\n if (Number.isNaN(expiresAt.getTime())) {\n throw new GithubIntegrationProviderError(\n 'malformed-provider-response',\n 'GitHub installation access token response did not include a valid expiry',\n );\n }\n\n return {\n token: response.data.token,\n expiresAt,\n ...(response.data.permissions === undefined ? {} : {permissions: response.data.permissions}),\n };\n }\n\n private getApp(): App {\n if (!this.app) {\n this.app = new App({\n appId: config.GITHUB_APP_ID,\n privateKey: normalizedGithubPrivateKey(),\n Octokit: Octokit.defaults({\n baseUrl: normalizedGithubApiBaseUrl(),\n throttle: {\n onRateLimit: (\n _retryAfter: number,\n _options: unknown,\n _octokit: unknown,\n retryCount: number,\n ) => retryCount === 0,\n onSecondaryRateLimit: (\n _retryAfter: number,\n _options: unknown,\n _octokit: unknown,\n retryCount: number,\n ) => retryCount === 0,\n },\n }),\n });\n }\n return this.app;\n }\n}\n\nclass InMemoryInstallationTokenCache implements InstallationTokenCache {\n private readonly tokens = new Map<number, GithubInstallationAccessToken>();\n private readonly inFlightMints = new Map<number, Promise<GithubInstallationAccessToken>>();\n\n constructor(\n private readonly options: {\n refreshMarginMs: number;\n now: () => Date;\n } = {\n refreshMarginMs: TOKEN_REFRESH_MARGIN_MS,\n now: () => new Date(),\n },\n ) {}\n\n getOrMint(\n installationId: number,\n mint: () => Promise<GithubInstallationAccessToken>,\n ): Promise<GithubInstallationAccessToken> {\n const cached = this.tokens.get(installationId);\n if (cached && !this.isInsideRefreshMargin(cached.expiresAt)) {\n recordInstallationTokenLookup('ram-hit');\n return Promise.resolve(cached);\n }\n\n const inFlightMint = this.inFlightMints.get(installationId);\n if (inFlightMint) return inFlightMint;\n\n const freshToken = mint()\n .then((token) => {\n this.tokens.set(installationId, token);\n return token;\n })\n .finally(() => {\n this.inFlightMints.delete(installationId);\n });\n this.inFlightMints.set(installationId, freshToken);\n return freshToken;\n }\n\n private isInsideRefreshMargin(expiresAt: Date): boolean {\n return expiresAt.getTime() <= this.options.now().getTime() + this.options.refreshMarginMs;\n }\n}\n\nclass TieredInstallationTokenCache implements InstallationTokenCache {\n constructor(\n private readonly ram: InstallationTokenCache,\n private readonly shared: InstallationTokenCache,\n ) {}\n\n getOrMint(\n installationId: number,\n mint: () => Promise<GithubInstallationAccessToken>,\n ): Promise<GithubInstallationAccessToken> {\n return this.ram.getOrMint(installationId, () => this.shared.getOrMint(installationId, mint));\n }\n}\n\nfunction createInstallationTokenCache(\n options: GithubInstallationTokenProviderOptions,\n): InstallationTokenCache {\n if (options.cache) return options.cache;\n\n const ram = new InMemoryInstallationTokenCache({\n refreshMarginMs: TOKEN_REFRESH_MARGIN_MS,\n now: options.now ?? (() => new Date()),\n });\n if (!options.getIntegrationConnectionById || !options.secretStore) return ram;\n\n const shared = new SharedInstallationTokenCache({\n secretStore: options.secretStore,\n withLock: options.withLock ?? withInstallationTokenLock,\n resolveWorkspaceId: createGithubInstallationWorkspaceResolver(\n options.getIntegrationConnectionById,\n ),\n now: options.now,\n });\n return new TieredInstallationTokenCache(ram, shared);\n}\n\nfunction createGithubInstallationWorkspaceResolver(\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn,\n) {\n return async (installationId: number): Promise<string> => {\n const installation = await getGithubInstallationByInstallationId(String(installationId));\n if (!installation) {\n throw new GithubIntegrationProviderError(\n 'installation-not-found',\n `GitHub installation not found: ${installationId}`,\n );\n }\n\n const connection = await getIntegrationConnectionById(installation.connectionId);\n if (!connection) {\n throw new GithubIntegrationProviderError(\n 'installation-not-found',\n `GitHub installation connection not found: ${installation.connectionId}`,\n );\n }\n return connection.workspaceId;\n };\n}\n\nexport function deleteGithubInstallationTokenSecret(params: {\n workspaceId: string;\n installationId: number;\n deleteSecrets: (params: {workspaceId: string; namespace: string}) => Promise<number>;\n}): Promise<number> {\n return params.deleteSecrets({\n workspaceId: params.workspaceId,\n namespace: githubInstallationTokenNamespace(params.installationId),\n });\n}\n"],"names":["App","Octokit","config","normalizedGithubApiBaseUrl","normalizedGithubPrivateKey","GithubIntegrationProviderError","withInstallationTokenLock","getGithubInstallationByInstallationId","recordInstallationTokenLookup","mapGithubError","githubInstallationTokenNamespace","TOKEN_REFRESH_MARGIN_MS","SharedInstallationTokenCache","createGithubInstallationTokenProvider","options","OctokitGithubInstallationTokenProvider","createInstallationTokenCache","cache","InMemoryInstallationTokenCache","getInstallationAccessToken","installationId","getOrMint","mintInstallationAccessToken","response","getApp","octokit","rest","apps","createInstallationAccessToken","installation_id","data","token","expiresAt","Date","expires_at","Number","isNaN","getTime","permissions","undefined","app","appId","GITHUB_APP_ID","privateKey","defaults","baseUrl","throttle","onRateLimit","_retryAfter","_options","_octokit","retryCount","onSecondaryRateLimit","refreshMarginMs","now","tokens","Map","inFlightMints","mint","cached","get","isInsideRefreshMargin","Promise","resolve","inFlightMint","freshToken","then","set","finally","delete","TieredInstallationTokenCache","ram","shared","getIntegrationConnectionById","secretStore","withLock","resolveWorkspaceId","createGithubInstallationWorkspaceResolver","installation","String","connection","connectionId","workspaceId","deleteGithubInstallationTokenSecret","params","deleteSecrets","namespace"],"mappings":"AACA,SAAQA,GAAG,EAAEC,OAAO,QAAO,UAAU;AACrC,SAAQC,MAAM,EAAEC,0BAA0B,EAAEC,0BAA0B,QAAO,aAAa;AAC1F,SAAQC,8BAA8B,QAAO,kBAAkB;AAC/D,SAAQC,yBAAyB,QAAO,iCAAiC;AACzE,SAAQC,qCAAqC,QAAO,uBAAuB;AAC3E,SAAQC,6BAA6B,QAAO,oBAAoB;AAChE,SAA4CC,cAAc,QAAO,cAAc;AAC/E,SACEC,gCAAgC,EAChCC,uBAAuB,QAClB,mCAAmC;AAC1C,SAGEC,4BAA4B,QAEvB,uCAAuC;AAc9C,OAAO,SAASC,sCACdC,UAAkD,CAAC,CAAC;IAEpD,OAAO,IAAIC,uCAAuCC,6BAA6BF;AACjF;AAEA,IAAA,AAAMC,yCAAN,MAAMA;IAGJ,YACE,AAAiBE,QAAgC,IAAIC,gCAAgC,CACrF;aADiBD,QAAAA;IAChB;IAEHE,2BAA2BC,cAAsB,EAA0C;QACzF,OAAO,IAAI,CAACH,KAAK,CAACI,SAAS,CAACD,gBAAgB,IAC1C,IAAI,CAACE,2BAA2B,CAACF;IAErC;IAEA,MAAcE,4BACZF,cAAsB,EACkB;QACxC,MAAMG,WAAW,MAAMd,eACrB,IACE,IAAI,CAACe,MAAM,GAAGC,OAAO,CAACC,IAAI,CAACC,IAAI,CAACC,6BAA6B,CAAC;gBAC5DC,iBAAiBT;YACnB,IACF;QAGF,IAAI,OAAOG,SAASO,IAAI,CAACC,KAAK,KAAK,UAAU;YAC3C,MAAM,IAAI1B,+BACR,+BACA;QAEJ;QAEA,MAAM2B,YAAY,IAAIC,KAAKV,SAASO,IAAI,CAACI,UAAU;QACnD,IAAIC,OAAOC,KAAK,CAACJ,UAAUK,OAAO,KAAK;YACrC,MAAM,IAAIhC,+BACR,+BACA;QAEJ;QAEA,OAAO;YACL0B,OAAOR,SAASO,IAAI,CAACC,KAAK;YAC1BC;YACA,GAAIT,SAASO,IAAI,CAACQ,WAAW,KAAKC,YAAY,CAAC,IAAI;gBAACD,aAAaf,SAASO,IAAI,CAACQ,WAAW;YAAA,CAAC;QAC7F;IACF;IAEQd,SAAc;QACpB,IAAI,CAAC,IAAI,CAACgB,GAAG,EAAE;YACb,IAAI,CAACA,GAAG,GAAG,IAAIxC,IAAI;gBACjByC,OAAOvC,OAAOwC,aAAa;gBAC3BC,YAAYvC;gBACZH,SAASA,QAAQ2C,QAAQ,CAAC;oBACxBC,SAAS1C;oBACT2C,UAAU;wBACRC,aAAa,CACXC,aACAC,UACAC,UACAC,aACGA,eAAe;wBACpBC,sBAAsB,CACpBJ,aACAC,UACAC,UACAC,aACGA,eAAe;oBACtB;gBACF;YACF;QACF;QACA,OAAO,IAAI,CAACX,GAAG;IACjB;AACF;AAEA,IAAA,AAAMtB,iCAAN,MAAMA;IAIJ,YACE,AAAiBJ,UAGb;QACFuC,iBAAiB1C;QACjB2C,KAAK,IAAM,IAAIrB;IACjB,CAAC,CACD;aAPiBnB,UAAAA;aAJFyC,SAAS,IAAIC;aACbC,gBAAgB,IAAID;IAUlC;IAEHnC,UACED,cAAsB,EACtBsC,IAAkD,EACV;QACxC,MAAMC,SAAS,IAAI,CAACJ,MAAM,CAACK,GAAG,CAACxC;QAC/B,IAAIuC,UAAU,CAAC,IAAI,CAACE,qBAAqB,CAACF,OAAO3B,SAAS,GAAG;YAC3DxB,8BAA8B;YAC9B,OAAOsD,QAAQC,OAAO,CAACJ;QACzB;QAEA,MAAMK,eAAe,IAAI,CAACP,aAAa,CAACG,GAAG,CAACxC;QAC5C,IAAI4C,cAAc,OAAOA;QAEzB,MAAMC,aAAaP,OAChBQ,IAAI,CAAC,CAACnC;YACL,IAAI,CAACwB,MAAM,CAACY,GAAG,CAAC/C,gBAAgBW;YAChC,OAAOA;QACT,GACCqC,OAAO,CAAC;YACP,IAAI,CAACX,aAAa,CAACY,MAAM,CAACjD;QAC5B;QACF,IAAI,CAACqC,aAAa,CAACU,GAAG,CAAC/C,gBAAgB6C;QACvC,OAAOA;IACT;IAEQJ,sBAAsB7B,SAAe,EAAW;QACtD,OAAOA,UAAUK,OAAO,MAAM,IAAI,CAACvB,OAAO,CAACwC,GAAG,GAAGjB,OAAO,KAAK,IAAI,CAACvB,OAAO,CAACuC,eAAe;IAC3F;AACF;AAEA,IAAA,AAAMiB,+BAAN,MAAMA;IACJ,YACE,AAAiBC,GAA2B,EAC5C,AAAiBC,MAA8B,CAC/C;aAFiBD,MAAAA;aACAC,SAAAA;IAChB;IAEHnD,UACED,cAAsB,EACtBsC,IAAkD,EACV;QACxC,OAAO,IAAI,CAACa,GAAG,CAAClD,SAAS,CAACD,gBAAgB,IAAM,IAAI,CAACoD,MAAM,CAACnD,SAAS,CAACD,gBAAgBsC;IACxF;AACF;AAEA,SAAS1C,6BACPF,OAA+C;IAE/C,IAAIA,QAAQG,KAAK,EAAE,OAAOH,QAAQG,KAAK;IAEvC,MAAMsD,MAAM,IAAIrD,+BAA+B;QAC7CmC,iBAAiB1C;QACjB2C,KAAKxC,QAAQwC,GAAG,IAAK,CAAA,IAAM,IAAIrB,MAAK;IACtC;IACA,IAAI,CAACnB,QAAQ2D,4BAA4B,IAAI,CAAC3D,QAAQ4D,WAAW,EAAE,OAAOH;IAE1E,MAAMC,SAAS,IAAI5D,6BAA6B;QAC9C8D,aAAa5D,QAAQ4D,WAAW;QAChCC,UAAU7D,QAAQ6D,QAAQ,IAAIrE;QAC9BsE,oBAAoBC,0CAClB/D,QAAQ2D,4BAA4B;QAEtCnB,KAAKxC,QAAQwC,GAAG;IAClB;IACA,OAAO,IAAIgB,6BAA6BC,KAAKC;AAC/C;AAEA,SAASK,0CACPJ,4BAA4D;IAE5D,OAAO,OAAOrD;QACZ,MAAM0D,eAAe,MAAMvE,sCAAsCwE,OAAO3D;QACxE,IAAI,CAAC0D,cAAc;YACjB,MAAM,IAAIzE,+BACR,0BACA,CAAC,+BAA+B,EAAEe,gBAAgB;QAEtD;QAEA,MAAM4D,aAAa,MAAMP,6BAA6BK,aAAaG,YAAY;QAC/E,IAAI,CAACD,YAAY;YACf,MAAM,IAAI3E,+BACR,0BACA,CAAC,0CAA0C,EAAEyE,aAAaG,YAAY,EAAE;QAE5E;QACA,OAAOD,WAAWE,WAAW;IAC/B;AACF;AAEA,OAAO,SAASC,oCAAoCC,MAInD;IACC,OAAOA,OAAOC,aAAa,CAAC;QAC1BH,aAAaE,OAAOF,WAAW;QAC/BI,WAAW5E,iCAAiC0E,OAAOhE,cAAc;IACnE;AACF"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { GithubInstallationAccessToken } from './client.js';
|
|
2
|
+
import { type InstallationTokenEnvelope } from './installation-token-envelope.js';
|
|
3
|
+
export interface InstallationTokenCache {
|
|
4
|
+
getOrMint(installationId: number, mint: () => Promise<GithubInstallationAccessToken>): Promise<GithubInstallationAccessToken>;
|
|
5
|
+
}
|
|
6
|
+
export type InstallationTokenLockResult<T> = {
|
|
7
|
+
acquired: true;
|
|
8
|
+
value: T;
|
|
9
|
+
} | {
|
|
10
|
+
acquired: false;
|
|
11
|
+
};
|
|
12
|
+
export interface InstallationTokenSecretStore {
|
|
13
|
+
read(workspaceId: string, installationId: number): Promise<string | null>;
|
|
14
|
+
write(workspaceId: string, installationId: number, envelope: InstallationTokenEnvelope): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface SharedInstallationTokenCacheOptions {
|
|
17
|
+
secretStore: InstallationTokenSecretStore;
|
|
18
|
+
withLock: <T>(installationId: number, fn: () => Promise<T>) => Promise<InstallationTokenLockResult<T>>;
|
|
19
|
+
resolveWorkspaceId: (installationId: number) => Promise<string>;
|
|
20
|
+
now?: (() => Date) | undefined;
|
|
21
|
+
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
22
|
+
pollDelaysMs?: number[] | undefined;
|
|
23
|
+
workspaceCacheTtlMs?: number | undefined;
|
|
24
|
+
mintTimeoutMs?: number | undefined;
|
|
25
|
+
}
|
|
26
|
+
export declare class SharedInstallationTokenCache implements InstallationTokenCache {
|
|
27
|
+
private readonly options;
|
|
28
|
+
private readonly workspaceIds;
|
|
29
|
+
private readonly now;
|
|
30
|
+
private readonly sleep;
|
|
31
|
+
private readonly pollDelaysMs;
|
|
32
|
+
private readonly workspaceCacheTtlMs;
|
|
33
|
+
private readonly mintTimeoutMs;
|
|
34
|
+
constructor(options: SharedInstallationTokenCacheOptions);
|
|
35
|
+
getOrMint(installationId: number, mint: () => Promise<GithubInstallationAccessToken>): Promise<GithubInstallationAccessToken>;
|
|
36
|
+
private mintUnderLock;
|
|
37
|
+
private serveStaleOrPoll;
|
|
38
|
+
private recordMint;
|
|
39
|
+
private readEnvelope;
|
|
40
|
+
private writeEnvelope;
|
|
41
|
+
private resolveWorkspaceId;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=shared-installation-token-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared-installation-token-cache.d.ts","sourceRoot":"","sources":["../../src/api/shared-installation-token-cache.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAC,6BAA6B,EAAC,MAAM,aAAa,CAAC;AAC/D,OAAO,EAIL,KAAK,yBAAyB,EAO/B,MAAM,kCAAkC,CAAC;AAE1C,MAAM,WAAW,sBAAsB;IACrC,SAAS,CACP,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,MAAM,OAAO,CAAC,6BAA6B,CAAC,GACjD,OAAO,CAAC,6BAA6B,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAAC,QAAQ,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAC,GAAG;IAAC,QAAQ,EAAE,KAAK,CAAA;CAAC,CAAC;AAE5F,MAAM,WAAW,4BAA4B;IAC3C,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1E,KAAK,CACH,WAAW,EAAE,MAAM,EACnB,cAAc,EAAE,MAAM,EACtB,QAAQ,EAAE,yBAAyB,GAClC,OAAO,CAAC,IAAI,CAAC,CAAC;CAClB;AAED,MAAM,WAAW,mCAAmC;IAClD,WAAW,EAAE,4BAA4B,CAAC;IAC1C,QAAQ,EAAE,CAAC,CAAC,EACV,cAAc,EAAE,MAAM,EACtB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KACjB,OAAO,CAAC,2BAA2B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7C,kBAAkB,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChE,GAAG,CAAC,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC;IAC/B,KAAK,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,CAAC;IACpD,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IACpC,mBAAmB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACpC;AAMD,qBAAa,4BAA6B,YAAW,sBAAsB;IAQ7D,OAAO,CAAC,QAAQ,CAAC,OAAO;IAPpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAiE;IAC9F,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAa;IACjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgC;IACtD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAW;IACxC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;gBAEV,OAAO,EAAE,mCAAmC;IAQnE,SAAS,CACb,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,MAAM,OAAO,CAAC,6BAA6B,CAAC,GACjD,OAAO,CAAC,6BAA6B,CAAC;YAgB3B,aAAa;YAkGb,gBAAgB;YA0ChB,UAAU;YAcV,YAAY;YAcZ,aAAa;YAQb,kBAAkB;CAYjC"}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { setTimeout as sleepTimeout } from 'node:timers/promises';
|
|
2
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
3
|
+
import { GithubIntegrationProviderError } from '#core/errors.js';
|
|
4
|
+
import { recordInstallationTokenBackoff, recordInstallationTokenLookup, recordInstallationTokenMint } from '#metrics/index.js';
|
|
5
|
+
import { backoffActive, backoffMs, classifyMintError, mintErrorClassForReason, parseInstallationTokenEnvelope, providerErrorFromBackoff, stillValid, toProviderError, usable } from './installation-token-envelope.js';
|
|
6
|
+
const DEFAULT_POLL_DELAYS_MS = [
|
|
7
|
+
100,
|
|
8
|
+
200,
|
|
9
|
+
400,
|
|
10
|
+
500,
|
|
11
|
+
800
|
|
12
|
+
];
|
|
13
|
+
const DEFAULT_WORKSPACE_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
14
|
+
const DEFAULT_MINT_TIMEOUT_MS = 30 * 1000;
|
|
15
|
+
export class SharedInstallationTokenCache {
|
|
16
|
+
constructor(options){
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.workspaceIds = new Map();
|
|
19
|
+
this.now = options.now ?? (()=>new Date());
|
|
20
|
+
this.sleep = options.sleep ?? ((ms)=>sleepTimeout(ms).then(()=>undefined));
|
|
21
|
+
this.pollDelaysMs = options.pollDelaysMs ?? DEFAULT_POLL_DELAYS_MS;
|
|
22
|
+
this.workspaceCacheTtlMs = options.workspaceCacheTtlMs ?? DEFAULT_WORKSPACE_CACHE_TTL_MS;
|
|
23
|
+
this.mintTimeoutMs = options.mintTimeoutMs ?? DEFAULT_MINT_TIMEOUT_MS;
|
|
24
|
+
}
|
|
25
|
+
async getOrMint(installationId, mint) {
|
|
26
|
+
const workspaceId = await this.resolveWorkspaceId(installationId);
|
|
27
|
+
const envelope = await this.readEnvelope(workspaceId, installationId);
|
|
28
|
+
if (usable(envelope, this.now())) {
|
|
29
|
+
recordInstallationTokenLookup('db-hit');
|
|
30
|
+
return tokenFromEnvelope(envelope);
|
|
31
|
+
}
|
|
32
|
+
const result = await this.options.withLock(installationId, ()=>this.mintUnderLock({
|
|
33
|
+
workspaceId,
|
|
34
|
+
installationId,
|
|
35
|
+
mint
|
|
36
|
+
}));
|
|
37
|
+
if (result.acquired) return result.value;
|
|
38
|
+
return await this.serveStaleOrPoll({
|
|
39
|
+
workspaceId,
|
|
40
|
+
installationId,
|
|
41
|
+
envelope
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async mintUnderLock(params) {
|
|
45
|
+
const envelope = await this.readEnvelope(params.workspaceId, params.installationId);
|
|
46
|
+
const now = this.now();
|
|
47
|
+
if (usable(envelope, now)) {
|
|
48
|
+
recordInstallationTokenLookup('db-hit');
|
|
49
|
+
return tokenFromEnvelope(envelope);
|
|
50
|
+
}
|
|
51
|
+
if (activeBackoff(envelope, now)) {
|
|
52
|
+
if (canServeStale(envelope, now)) {
|
|
53
|
+
recordInstallationTokenLookup('served-stale');
|
|
54
|
+
return tokenFromEnvelope(envelope);
|
|
55
|
+
}
|
|
56
|
+
recordInstallationTokenLookup('backoff');
|
|
57
|
+
throw providerErrorFromBackoff(envelope?.backoffReason ?? 'provider-unavailable', (envelope?.backoffUntil?.getTime() ?? now.getTime()) - now.getTime());
|
|
58
|
+
}
|
|
59
|
+
let token;
|
|
60
|
+
try {
|
|
61
|
+
token = await this.recordMint(params.mint);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const providerError = toProviderError(error);
|
|
64
|
+
const classified = classifyMintError(providerError);
|
|
65
|
+
const until = new Date(this.now().getTime() + backoffMs(classified));
|
|
66
|
+
recordInstallationTokenBackoff({
|
|
67
|
+
reason: classified.reason,
|
|
68
|
+
class: classified.class
|
|
69
|
+
});
|
|
70
|
+
await this.writeEnvelope(params.workspaceId, params.installationId, {
|
|
71
|
+
token: envelope?.token,
|
|
72
|
+
expiresAt: envelope?.expiresAt,
|
|
73
|
+
permissions: envelope?.permissions,
|
|
74
|
+
backoffUntil: until,
|
|
75
|
+
backoffReason: classified.reason
|
|
76
|
+
}).catch((writeError)=>{
|
|
77
|
+
logger().warn({
|
|
78
|
+
installationId: params.installationId,
|
|
79
|
+
reason: classified.reason,
|
|
80
|
+
error: writeError
|
|
81
|
+
}, 'github installation token backoff write failed');
|
|
82
|
+
});
|
|
83
|
+
if (classified.class === 'transient' && envelope?.token && stillValid(envelope.expiresAt, this.now())) {
|
|
84
|
+
logger().warn({
|
|
85
|
+
installationId: params.installationId,
|
|
86
|
+
expiresAt: envelope.expiresAt?.toISOString(),
|
|
87
|
+
reason: classified.reason,
|
|
88
|
+
backoffUntil: until.toISOString()
|
|
89
|
+
}, 'github installation token mint failed; serving stale token');
|
|
90
|
+
recordInstallationTokenLookup('served-stale');
|
|
91
|
+
return tokenFromEnvelope(envelope);
|
|
92
|
+
}
|
|
93
|
+
logger().warn({
|
|
94
|
+
installationId: params.installationId,
|
|
95
|
+
reason: classified.reason,
|
|
96
|
+
backoffUntil: until.toISOString(),
|
|
97
|
+
error: providerError
|
|
98
|
+
}, 'github installation token mint failed; backoff recorded');
|
|
99
|
+
recordInstallationTokenLookup('backoff');
|
|
100
|
+
throw providerError;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await this.writeEnvelope(params.workspaceId, params.installationId, {
|
|
104
|
+
token: token.token,
|
|
105
|
+
expiresAt: token.expiresAt,
|
|
106
|
+
permissions: token.permissions
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
logger().warn({
|
|
110
|
+
installationId: params.installationId,
|
|
111
|
+
expiresAt: token.expiresAt.toISOString(),
|
|
112
|
+
error
|
|
113
|
+
}, 'github installation token cache write failed after mint');
|
|
114
|
+
}
|
|
115
|
+
logger().info({
|
|
116
|
+
installationId: params.installationId,
|
|
117
|
+
expiresAt: token.expiresAt.toISOString()
|
|
118
|
+
}, 'github installation token minted');
|
|
119
|
+
recordInstallationTokenLookup('minted');
|
|
120
|
+
return token;
|
|
121
|
+
}
|
|
122
|
+
async serveStaleOrPoll(params) {
|
|
123
|
+
const initialNow = this.now();
|
|
124
|
+
if (canServeStale(params.envelope, initialNow)) {
|
|
125
|
+
recordInstallationTokenLookup('served-stale');
|
|
126
|
+
return tokenFromEnvelope(params.envelope);
|
|
127
|
+
}
|
|
128
|
+
if (activeBackoff(params.envelope, initialNow)) {
|
|
129
|
+
recordInstallationTokenLookup('backoff');
|
|
130
|
+
throw providerErrorFromBackoff(params.envelope.backoffReason, params.envelope.backoffUntil.getTime() - initialNow.getTime());
|
|
131
|
+
}
|
|
132
|
+
for (const delayMs of this.pollDelaysMs){
|
|
133
|
+
await this.sleep(delayMs);
|
|
134
|
+
const envelope = await this.readEnvelope(params.workspaceId, params.installationId);
|
|
135
|
+
const now = this.now();
|
|
136
|
+
if (usable(envelope, now)) {
|
|
137
|
+
recordInstallationTokenLookup('contended-poll');
|
|
138
|
+
return tokenFromEnvelope(envelope);
|
|
139
|
+
}
|
|
140
|
+
if (backoffActive(envelope, now)) {
|
|
141
|
+
recordInstallationTokenLookup('backoff');
|
|
142
|
+
throw providerErrorFromBackoff(envelope?.backoffReason ?? 'provider-unavailable', (envelope?.backoffUntil?.getTime() ?? now.getTime()) - now.getTime());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new GithubIntegrationProviderError('provider-unavailable', 'GitHub installation token mint is still in progress', 1);
|
|
146
|
+
}
|
|
147
|
+
async recordMint(mint) {
|
|
148
|
+
const startedAt = Date.now();
|
|
149
|
+
try {
|
|
150
|
+
const token = await withTimeout(mint(), this.mintTimeoutMs);
|
|
151
|
+
recordInstallationTokenMint({
|
|
152
|
+
outcome: 'success',
|
|
153
|
+
durationMs: Date.now() - startedAt
|
|
154
|
+
});
|
|
155
|
+
return token;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
recordInstallationTokenMint({
|
|
158
|
+
outcome: 'failure',
|
|
159
|
+
durationMs: Date.now() - startedAt
|
|
160
|
+
});
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async readEnvelope(workspaceId, installationId) {
|
|
165
|
+
const raw = await this.options.secretStore.read(workspaceId, installationId);
|
|
166
|
+
if (raw === null) return undefined;
|
|
167
|
+
const envelope = parseInstallationTokenEnvelope(raw);
|
|
168
|
+
if (envelope === undefined) {
|
|
169
|
+
logger().warn({
|
|
170
|
+
installationId
|
|
171
|
+
}, 'github installation token cache envelope failed to decode');
|
|
172
|
+
}
|
|
173
|
+
return envelope;
|
|
174
|
+
}
|
|
175
|
+
async writeEnvelope(workspaceId, installationId, envelope) {
|
|
176
|
+
await this.options.secretStore.write(workspaceId, installationId, envelope);
|
|
177
|
+
}
|
|
178
|
+
async resolveWorkspaceId(installationId) {
|
|
179
|
+
const nowMs = this.now().getTime();
|
|
180
|
+
const cached = this.workspaceIds.get(installationId);
|
|
181
|
+
if (cached && cached.expiresAtMs > nowMs) return cached.workspaceId;
|
|
182
|
+
const workspaceId = await this.options.resolveWorkspaceId(installationId);
|
|
183
|
+
this.workspaceIds.set(installationId, {
|
|
184
|
+
workspaceId,
|
|
185
|
+
expiresAtMs: nowMs + this.workspaceCacheTtlMs
|
|
186
|
+
});
|
|
187
|
+
return workspaceId;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function activeBackoff(envelope, now) {
|
|
191
|
+
return backoffActive(envelope, now) && envelope?.backoffUntil !== undefined && envelope.backoffReason !== undefined;
|
|
192
|
+
}
|
|
193
|
+
function canServeStale(envelope, now) {
|
|
194
|
+
const terminalBackoff = activeBackoff(envelope, now) && mintErrorClassForReason(envelope.backoffReason) === 'terminal';
|
|
195
|
+
return envelope?.token !== undefined && envelope.expiresAt !== undefined && stillValid(envelope.expiresAt, now) && !terminalBackoff;
|
|
196
|
+
}
|
|
197
|
+
function tokenFromEnvelope(envelope) {
|
|
198
|
+
if (!envelope.token || !envelope.expiresAt) {
|
|
199
|
+
throw new GithubIntegrationProviderError('malformed-provider-response', 'GitHub installation token cache envelope is missing a token or expiry');
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
token: envelope.token,
|
|
203
|
+
expiresAt: envelope.expiresAt,
|
|
204
|
+
...envelope.permissions === undefined ? {} : {
|
|
205
|
+
permissions: envelope.permissions
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function withTimeout(promise, timeoutMs) {
|
|
210
|
+
let timer;
|
|
211
|
+
const timeout = new Promise((_, reject)=>{
|
|
212
|
+
timer = setTimeout(()=>{
|
|
213
|
+
reject(new GithubIntegrationProviderError('timeout', 'Timed out minting GitHub installation access token'));
|
|
214
|
+
}, timeoutMs);
|
|
215
|
+
});
|
|
216
|
+
return Promise.race([
|
|
217
|
+
promise,
|
|
218
|
+
timeout
|
|
219
|
+
]).finally(()=>{
|
|
220
|
+
if (timer) clearTimeout(timer);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
//# sourceMappingURL=shared-installation-token-cache.js.map
|