jg-github 0.0.0-stage → 0.1.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/API.md +648 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/RECIPES.md +101 -0
- package/dist/app-auth/app-auth.d.ts +33 -0
- package/dist/app-auth/app-auth.js +95 -0
- package/dist/app-auth/app-auth.js.map +1 -0
- package/dist/app-auth/constants.d.ts +4 -0
- package/dist/app-auth/constants.js +10 -0
- package/dist/app-auth/constants.js.map +1 -0
- package/dist/app-auth/index.d.ts +2 -0
- package/dist/app-auth/index.js +3 -0
- package/dist/app-auth/index.js.map +1 -0
- package/dist/app-auth/types.d.ts +5 -0
- package/dist/app-auth/types.js +2 -0
- package/dist/app-auth/types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/installation-token/constants.d.ts +12 -0
- package/dist/installation-token/constants.js +30 -0
- package/dist/installation-token/constants.js.map +1 -0
- package/dist/installation-token/errors.d.ts +41 -0
- package/dist/installation-token/errors.js +94 -0
- package/dist/installation-token/errors.js.map +1 -0
- package/dist/installation-token/index.d.ts +2 -0
- package/dist/installation-token/index.js +3 -0
- package/dist/installation-token/index.js.map +1 -0
- package/dist/installation-token/installation-token.d.ts +57 -0
- package/dist/installation-token/installation-token.js +152 -0
- package/dist/installation-token/installation-token.js.map +1 -0
- package/dist/installation-token/types.d.ts +33 -0
- package/dist/installation-token/types.js +2 -0
- package/dist/installation-token/types.js.map +1 -0
- package/dist/installation-token/utilities.d.ts +36 -0
- package/dist/installation-token/utilities.js +93 -0
- package/dist/installation-token/utilities.js.map +1 -0
- package/dist/installation-token/validations.d.ts +35 -0
- package/dist/installation-token/validations.js +74 -0
- package/dist/installation-token/validations.js.map +1 -0
- package/dist/rate-limit/constants.d.ts +2 -0
- package/dist/rate-limit/constants.js +5 -0
- package/dist/rate-limit/constants.js.map +1 -0
- package/dist/rate-limit/index.d.ts +2 -0
- package/dist/rate-limit/index.js +3 -0
- package/dist/rate-limit/index.js.map +1 -0
- package/dist/rate-limit/rate-limit.d.ts +24 -0
- package/dist/rate-limit/rate-limit.js +88 -0
- package/dist/rate-limit/rate-limit.js.map +1 -0
- package/dist/rate-limit/types.d.ts +8 -0
- package/dist/rate-limit/types.js +2 -0
- package/dist/rate-limit/types.js.map +1 -0
- package/dist/shared/constants.d.ts +7 -0
- package/dist/shared/constants.js +15 -0
- package/dist/shared/constants.js.map +1 -0
- package/dist/shared/errors.d.ts +9 -0
- package/dist/shared/errors.js +11 -0
- package/dist/shared/errors.js.map +1 -0
- package/dist/shared/headers.d.ts +19 -0
- package/dist/shared/headers.js +37 -0
- package/dist/shared/headers.js.map +1 -0
- package/dist/shared/index.d.ts +6 -0
- package/dist/shared/index.js +11 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/shared/types.d.ts +17 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/shared/urls.d.ts +32 -0
- package/dist/shared/urls.js +110 -0
- package/dist/shared/urls.js.map +1 -0
- package/dist/shared/utilities.d.ts +20 -0
- package/dist/shared/utilities.js +25 -0
- package/dist/shared/utilities.js.map +1 -0
- package/dist/webhook/constants.d.ts +2 -0
- package/dist/webhook/constants.js +5 -0
- package/dist/webhook/constants.js.map +1 -0
- package/dist/webhook/index.d.ts +1 -0
- package/dist/webhook/index.js +3 -0
- package/dist/webhook/index.js.map +1 -0
- package/dist/webhook/webhook.d.ts +23 -0
- package/dist/webhook/webhook.js +35 -0
- package/dist/webhook/webhook.js.map +1 -0
- package/package.json +68 -3
package/README.md
CHANGED
|
@@ -1,3 +1,101 @@
|
|
|
1
|
-
#
|
|
1
|
+
# jg-github
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
GitHub App authentication for Node.js: app tokens, scoped installation tokens, webhook signatures, and rate-limit retry times.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install jg-github
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Create an installation token for exactly the repositories and the permissions a job needs, and revoke it when the job is done:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createInstallationToken, revokeInstallationToken } from 'jg-github';
|
|
17
|
+
|
|
18
|
+
declare const privateKey: string;
|
|
19
|
+
|
|
20
|
+
const { token, expiresAt } = await createInstallationToken({
|
|
21
|
+
appId: 123456,
|
|
22
|
+
privateKey,
|
|
23
|
+
installationId: 12345678,
|
|
24
|
+
repositories: ['app', 'docs'], // or repositoryIds: [101, 102]
|
|
25
|
+
permissions: { contents: 'read', pull_requests: 'write' },
|
|
26
|
+
userAgent: 'acme-release-bot',
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
// call the API with createGithubHeaders(token, { userAgent: 'acme-release-bot' })
|
|
30
|
+
} finally {
|
|
31
|
+
await revokeInstallationToken(token, { userAgent: 'acme-release-bot' });
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`permissions` is required and can't be empty, because GitHub grants every permission of the installation to a request without it. The repositories are named by name or by ID, never both, with no repeats. Anything else throws `GITHUB_INVALID_ARGUMENT` before a request is sent. GitHub's answer must match the request exactly: only the selected repositories, the same ones, compared by name ignoring case or by ID as you named them, and the same permissions, plus the `metadata: 'read'` GitHub adds. A token that doesn't match, or that had already expired, is revoked before the error is thrown, and the error's `isRevoked` says whether that worked.
|
|
36
|
+
|
|
37
|
+
Verify a webhook delivery before parsing its body:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { verifyGithubWebhookSignature } from 'jg-github';
|
|
41
|
+
|
|
42
|
+
declare const rawBody: Buffer;
|
|
43
|
+
declare const headers: Record<string, string | string[] | undefined>;
|
|
44
|
+
declare const webhookSecret: string;
|
|
45
|
+
|
|
46
|
+
const isAuthentic = verifyGithubWebhookSignature(
|
|
47
|
+
rawBody,
|
|
48
|
+
headers['x-hub-signature-256'],
|
|
49
|
+
webhookSecret,
|
|
50
|
+
);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It's false for a header that's missing, repeated, longer than 71 characters, or not `sha256=` and 64 lowercase hexadecimal characters, and compares the signature in constant time.
|
|
54
|
+
|
|
55
|
+
Find when to retry a rate limited request:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { getGithubRateLimitDeadline } from 'jg-github';
|
|
59
|
+
|
|
60
|
+
declare const response: Response;
|
|
61
|
+
|
|
62
|
+
const retryAt = getGithubRateLimitDeadline({
|
|
63
|
+
status: response.status,
|
|
64
|
+
headers: response.headers,
|
|
65
|
+
retryCount: 0,
|
|
66
|
+
}); // a Date, or null when the response isn't rate limited
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A 429 is rate limited, and so is a 403 with `Retry-After`, with no requests remaining, or with `hasRateLimitMessage`, and a success that used the last request. The deadline is the latest future one of `Retry-After`, as whole seconds or a strict IMF-fixdate, and `x-ratelimit-reset` when no requests remain; without one, it's a minute from now, doubled for each retry already made.
|
|
70
|
+
|
|
71
|
+
The rest of the API:
|
|
72
|
+
|
|
73
|
+
- `createGithubAppJwt({ appId, privateKey })`, the App's own JWT, signed with RS256, issued 60 seconds back and expiring 540 seconds ahead, inside GitHub's ten-minute limit even with a clock a minute ahead. `assertGithubAppPrivateKey(pem)` checks the key at startup: an unencrypted RSA key, PKCS#1 or PKCS#8.
|
|
74
|
+
- `createGithubHeaders(token, { userAgent, apiVersion })`, the headers every request sends, with `X-GitHub-Api-Version` set to `GITHUB_API_VERSION` unless you pass another.
|
|
75
|
+
- `buildGithubApiUrl(segments, query, { apiOrigin })` and `buildGithubRawContentUrl(location, { rawContentOrigin })`, which encode each value, so a branch's `/` or a `..` can't change the path. `apiOrigin`, such as GitHub Enterprise Server's `https://ghe.example.com/api/v3`, must be https, or http on a loopback host for a test server.
|
|
76
|
+
|
|
77
|
+
Requests go through jg-fetch with `redirect: 'error'`, a 10-second timeout, a 128 KiB response limit, and no retries, because creating or revoking a token twice isn't safe.
|
|
78
|
+
|
|
79
|
+
### Errors
|
|
80
|
+
|
|
81
|
+
| Code | When |
|
|
82
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
83
|
+
| `GITHUB_PRIVATE_KEY_INVALID` | The App's private key isn't an unencrypted RSA key, PEM-encoded as PKCS#1 or PKCS#8 |
|
|
84
|
+
| `GITHUB_INVALID_ARGUMENT` | An argument isn't valid, such as missing or empty `permissions`, a repeated repository, or an installation ID not digits |
|
|
85
|
+
| `GITHUB_REQUEST_FAILED` | A request fails: an error status, a redirect, a timeout, an abort, or no answer |
|
|
86
|
+
| `GITHUB_RESPONSE_INVALID` | GitHub's answer isn't in the expected format, or is larger than `maxResponseBytes` |
|
|
87
|
+
| `GITHUB_TOKEN_SCOPE_MISMATCH` | The granted token reaches other repositories, or has other permissions, than requested |
|
|
88
|
+
| `GITHUB_TOKEN_EXPIRED` | The granted token had already expired |
|
|
89
|
+
| `GITHUB_TOKEN_REVOKE_FAILED` | GitHub answers a revocation with anything but 204, or not at all |
|
|
90
|
+
|
|
91
|
+
`GITHUB_REQUEST_FAILED`'s data holds jg-fetch's `kind` of failure, the `status`, and the `rateLimitDeadline` as an ISO string, or null. `GITHUB_TOKEN_SCOPE_MISMATCH`'s data says what differs, `repository_selection`, `repositories`, or `permissions`, and, like `GITHUB_TOKEN_EXPIRED`'s and `GITHUB_RESPONSE_INVALID`'s when GitHub granted a token, whether it was revoked. No error has a cause or holds a token, the App's JWT, the private key, or a response body. `verifyGithubWebhookSignature` throws jg-crypto's `INVALID_HMAC_SECRET` for an empty secret.
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
The full API reference ships in the package as `API.md`, generated from its TSDoc for the installed version, and is at https://jg-packages.jesusgraterol.dev/packages/jg-github/.
|
|
96
|
+
|
|
97
|
+
Recipes for common jobs ship as `RECIPES.md`, with every block typechecked against the release, and are at https://jg-packages.jesusgraterol.dev/packages/jg-github/recipes/.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
[MIT](https://github.com/jesusgraterol/jg-packages/blob/main/packages/jg-github/LICENSE)
|
package/RECIPES.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# jg-github recipes
|
|
2
|
+
|
|
3
|
+
Patterns for services that act as a GitHub App: short-lived tokens scoped to one job, and webhook routes that trust only what GitHub signed.
|
|
4
|
+
|
|
5
|
+
## A scoped installation token, revoked when done
|
|
6
|
+
|
|
7
|
+
Use this when a job reads or writes a repository on an installation's behalf, such as a release bot that reads a repository's changelog. The token can reach that one repository with read access to its contents, nothing else the installation allows, and it's revoked as soon as the job ends instead of staying valid for an hour:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { request } from 'jg-fetch';
|
|
11
|
+
import {
|
|
12
|
+
buildGithubApiUrl,
|
|
13
|
+
createGithubHeaders,
|
|
14
|
+
createInstallationToken,
|
|
15
|
+
revokeInstallationToken,
|
|
16
|
+
} from 'jg-github';
|
|
17
|
+
|
|
18
|
+
// the App's credentials and the installation, from the app's configuration
|
|
19
|
+
declare const appId: number;
|
|
20
|
+
declare const privateKey: string;
|
|
21
|
+
declare const installationId: number;
|
|
22
|
+
|
|
23
|
+
const USER_AGENT = 'acme-release-bot';
|
|
24
|
+
|
|
25
|
+
// reads a repository's CHANGELOG.md, as text, from the default branch
|
|
26
|
+
export const readChangelog = async (owner: string, repository: string): Promise<string> => {
|
|
27
|
+
const { token } = await createInstallationToken({
|
|
28
|
+
appId,
|
|
29
|
+
privateKey,
|
|
30
|
+
installationId,
|
|
31
|
+
repositories: [repository],
|
|
32
|
+
permissions: { contents: 'read' },
|
|
33
|
+
userAgent: USER_AGENT,
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
const { data, error } = await request(
|
|
37
|
+
buildGithubApiUrl(['repos', owner, repository, 'contents', 'CHANGELOG.md']),
|
|
38
|
+
{
|
|
39
|
+
headers: {
|
|
40
|
+
...createGithubHeaders(token, { userAgent: USER_AGENT }),
|
|
41
|
+
accept: 'application/vnd.github.raw+json',
|
|
42
|
+
},
|
|
43
|
+
parseAs: 'text',
|
|
44
|
+
maxResponseBytes: 1_048_576,
|
|
45
|
+
init: { redirect: 'error' },
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
if (error !== null) {
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
return data;
|
|
52
|
+
} finally {
|
|
53
|
+
try {
|
|
54
|
+
await revokeInstallationToken(token, { userAgent: USER_AGENT });
|
|
55
|
+
} catch {
|
|
56
|
+
// the token still expires within the hour; the error never holds it
|
|
57
|
+
console.error("The release bot's installation token couldn't be revoked.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`permissions` must name what the job needs: GitHub reads a request without it as every permission the installation has, so `createInstallationToken` refuses one before sending it. The token GitHub grants is checked against the request, and one that reaches another repository or has another permission is revoked and refused with `GITHUB_TOKEN_SCOPE_MISMATCH`. Revoking in a `finally` covers a job that throws, and catching the revocation's own failure keeps it from replacing the job's error.
|
|
64
|
+
|
|
65
|
+
## A webhook route
|
|
66
|
+
|
|
67
|
+
Use this in the route that receives a GitHub App's webhook deliveries. GitHub signs each body with the webhook's secret and sends the signature in `X-Hub-Signature-256`; verify it over the exact bytes received, before parsing them, because parsing and serializing can change the bytes:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { verifyGithubWebhookSignature } from 'jg-github';
|
|
71
|
+
|
|
72
|
+
// the request as your framework exposes it, with the body kept as raw bytes
|
|
73
|
+
type IWebhookRequest = {
|
|
74
|
+
headers: Record<string, string | string[] | undefined>;
|
|
75
|
+
rawBody: Buffer;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// the webhook's secret, from the app's configuration
|
|
79
|
+
declare const webhookSecret: string;
|
|
80
|
+
|
|
81
|
+
// the outcome of a delivery: a status for the response, and the event when it's authentic
|
|
82
|
+
export const receiveGithubWebhook = (
|
|
83
|
+
request: IWebhookRequest,
|
|
84
|
+
): { status: number; event: { name: string; deliveryId: string; payload: unknown } | null } => {
|
|
85
|
+
const { headers, rawBody } = request;
|
|
86
|
+
if (!verifyGithubWebhookSignature(rawBody, headers['x-hub-signature-256'], webhookSecret)) {
|
|
87
|
+
return { status: 401, event: null };
|
|
88
|
+
}
|
|
89
|
+
const name = headers['x-github-event'];
|
|
90
|
+
const deliveryId = headers['x-github-delivery'];
|
|
91
|
+
if (typeof name !== 'string' || typeof deliveryId !== 'string') {
|
|
92
|
+
return { status: 400, event: null };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
status: 202,
|
|
96
|
+
event: { name, deliveryId, payload: JSON.parse(rawBody.toString('utf8')) },
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`verifyGithubWebhookSignature` treats a missing, repeated, overlong, or malformed header as a wrong signature, so the route needs no checks of its own, and compares in constant time, so the responses' timing reveals nothing about the signature. GitHub redelivers a delivery that failed, with the same `X-GitHub-Delivery`, so a handler with side effects records the delivery IDs it has processed and skips a repeat.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ICreateGithubAppJwtOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Checks that a GitHub App's private key can sign its JWTs: an unencrypted RSA key, PEM-encoded
|
|
4
|
+
* as PKCS#1, the `RSA PRIVATE KEY` GitHub downloads, or PKCS#8, the `PRIVATE KEY` it converts to.
|
|
5
|
+
* Run it at startup, so a bad key fails before the first request. The error never holds the key.
|
|
6
|
+
* @param pem The key.
|
|
7
|
+
* @example
|
|
8
|
+
* import { assertGithubAppPrivateKey } from 'jg-github';
|
|
9
|
+
*
|
|
10
|
+
* declare const privateKey: string;
|
|
11
|
+
*
|
|
12
|
+
* assertGithubAppPrivateKey(privateKey);
|
|
13
|
+
* @throws
|
|
14
|
+
* - GITHUB_PRIVATE_KEY_INVALID: The GitHub App's private key isn't an unencrypted RSA private key.
|
|
15
|
+
*/
|
|
16
|
+
export declare const assertGithubAppPrivateKey: (pem: string) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Creates the JWT a GitHub App authenticates as itself with, signed with RS256: issued 60 seconds
|
|
19
|
+
* back, for a GitHub clock behind this one, and expiring 540 seconds ahead, inside GitHub's
|
|
20
|
+
* ten-minute limit even with this clock a minute ahead.
|
|
21
|
+
* @param options The App's ID, its private key, and the time.
|
|
22
|
+
* @returns The JWT.
|
|
23
|
+
* @example
|
|
24
|
+
* import { createGithubAppJwt } from 'jg-github';
|
|
25
|
+
*
|
|
26
|
+
* declare const privateKey: string;
|
|
27
|
+
*
|
|
28
|
+
* const jwt = createGithubAppJwt({ appId: 123456, privateKey });
|
|
29
|
+
* @throws
|
|
30
|
+
* - GITHUB_INVALID_ARGUMENT: An argument isn't valid.
|
|
31
|
+
* - GITHUB_PRIVATE_KEY_INVALID: The GitHub App's private key isn't an unencrypted RSA private key.
|
|
32
|
+
*/
|
|
33
|
+
export declare const createGithubAppJwt: ({ appId, privateKey, now, }: ICreateGithubAppJwtOptions) => string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createPrivateKey, sign } from 'node:crypto';
|
|
2
|
+
import { Exception } from 'jg-exception';
|
|
3
|
+
import { ERRORS, invalidArgument } from '../shared/index.js';
|
|
4
|
+
import { APP_ID_PATTERN, JWT_CLOCK_SKEW_SECONDS, JWT_LIFETIME_SECONDS, PRIVATE_KEY_PEM_PATTERN, } from './constants.js';
|
|
5
|
+
/**
|
|
6
|
+
* Builds the exception for a private key that can't sign an App JWT, without the key or the
|
|
7
|
+
* parser's error, which could quote it.
|
|
8
|
+
* @returns The GITHUB_PRIVATE_KEY_INVALID exception.
|
|
9
|
+
*/
|
|
10
|
+
const privateKeyInvalid = () => new Exception("The GitHub App's private key isn't an unencrypted RSA private key.", {
|
|
11
|
+
code: ERRORS.GITHUB_PRIVATE_KEY_INVALID,
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* Reads a GitHub App's private key: an unencrypted RSA key, PEM-encoded as PKCS#1 or PKCS#8.
|
|
15
|
+
* @param pem The key.
|
|
16
|
+
* @returns The key.
|
|
17
|
+
* @throws
|
|
18
|
+
* - GITHUB_PRIVATE_KEY_INVALID: The GitHub App's private key isn't an unencrypted RSA private key.
|
|
19
|
+
*/
|
|
20
|
+
const readPrivateKey = (pem) => {
|
|
21
|
+
const trimmed = typeof pem === 'string' ? pem.trim() : '';
|
|
22
|
+
if (!PRIVATE_KEY_PEM_PATTERN.test(trimmed)) {
|
|
23
|
+
throw privateKeyInvalid();
|
|
24
|
+
}
|
|
25
|
+
let key;
|
|
26
|
+
try {
|
|
27
|
+
key = createPrivateKey({ key: trimmed, format: 'pem' });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw privateKeyInvalid();
|
|
31
|
+
}
|
|
32
|
+
if (key.asymmetricKeyType !== 'rsa') {
|
|
33
|
+
throw privateKeyInvalid();
|
|
34
|
+
}
|
|
35
|
+
return key;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Encodes a value as JSON in base64url, as a JWT's header and payload are.
|
|
39
|
+
* @param value The value.
|
|
40
|
+
* @returns The encoded value.
|
|
41
|
+
*/
|
|
42
|
+
const encodeJson = (value) => Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
|
|
43
|
+
/**
|
|
44
|
+
* Checks that a GitHub App's private key can sign its JWTs: an unencrypted RSA key, PEM-encoded
|
|
45
|
+
* as PKCS#1, the `RSA PRIVATE KEY` GitHub downloads, or PKCS#8, the `PRIVATE KEY` it converts to.
|
|
46
|
+
* Run it at startup, so a bad key fails before the first request. The error never holds the key.
|
|
47
|
+
* @param pem The key.
|
|
48
|
+
* @example
|
|
49
|
+
* import { assertGithubAppPrivateKey } from 'jg-github';
|
|
50
|
+
*
|
|
51
|
+
* declare const privateKey: string;
|
|
52
|
+
*
|
|
53
|
+
* assertGithubAppPrivateKey(privateKey);
|
|
54
|
+
* @throws
|
|
55
|
+
* - GITHUB_PRIVATE_KEY_INVALID: The GitHub App's private key isn't an unencrypted RSA private key.
|
|
56
|
+
*/
|
|
57
|
+
export const assertGithubAppPrivateKey = (pem) => {
|
|
58
|
+
readPrivateKey(pem);
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Creates the JWT a GitHub App authenticates as itself with, signed with RS256: issued 60 seconds
|
|
62
|
+
* back, for a GitHub clock behind this one, and expiring 540 seconds ahead, inside GitHub's
|
|
63
|
+
* ten-minute limit even with this clock a minute ahead.
|
|
64
|
+
* @param options The App's ID, its private key, and the time.
|
|
65
|
+
* @returns The JWT.
|
|
66
|
+
* @example
|
|
67
|
+
* import { createGithubAppJwt } from 'jg-github';
|
|
68
|
+
*
|
|
69
|
+
* declare const privateKey: string;
|
|
70
|
+
*
|
|
71
|
+
* const jwt = createGithubAppJwt({ appId: 123456, privateKey });
|
|
72
|
+
* @throws
|
|
73
|
+
* - GITHUB_INVALID_ARGUMENT: An argument isn't valid.
|
|
74
|
+
* - GITHUB_PRIVATE_KEY_INVALID: The GitHub App's private key isn't an unencrypted RSA private key.
|
|
75
|
+
*/
|
|
76
|
+
export const createGithubAppJwt = ({ appId, privateKey, now = new Date(), }) => {
|
|
77
|
+
const issuer = typeof appId === 'number' ? String(appId) : appId;
|
|
78
|
+
if (typeof issuer !== 'string' ||
|
|
79
|
+
!APP_ID_PATTERN.test(issuer) ||
|
|
80
|
+
(typeof appId === 'number' && !(Number.isSafeInteger(appId) && appId > 0))) {
|
|
81
|
+
throw invalidArgument('appId');
|
|
82
|
+
}
|
|
83
|
+
if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
|
|
84
|
+
throw invalidArgument('now');
|
|
85
|
+
}
|
|
86
|
+
const key = readPrivateKey(privateKey);
|
|
87
|
+
const seconds = Math.floor(now.getTime() / 1000);
|
|
88
|
+
const unsigned = `${encodeJson({ alg: 'RS256', typ: 'JWT' })}.${encodeJson({
|
|
89
|
+
iat: seconds - JWT_CLOCK_SKEW_SECONDS,
|
|
90
|
+
exp: seconds + JWT_LIFETIME_SECONDS,
|
|
91
|
+
iss: issuer,
|
|
92
|
+
})}`;
|
|
93
|
+
return `${unsigned}.${sign('RSA-SHA256', Buffer.from(unsigned, 'utf8'), key).toString('base64url')}`;
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=app-auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app-auth.js","sourceRoot":"","sources":["../../src/app-auth/app-auth.ts"],"names":[],"mappings":"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","sourcesContent":["import { createPrivateKey, type KeyObject, sign } from 'node:crypto';\n\nimport { Exception } from 'jg-exception';\n\nimport { ERRORS, invalidArgument } from '../shared/index.js';\nimport {\n APP_ID_PATTERN,\n JWT_CLOCK_SKEW_SECONDS,\n JWT_LIFETIME_SECONDS,\n PRIVATE_KEY_PEM_PATTERN,\n} from './constants.js';\nimport type { ICreateGithubAppJwtOptions } from './types.js';\n\n/**\n * Builds the exception for a private key that can't sign an App JWT, without the key or the\n * parser's error, which could quote it.\n * @returns The GITHUB_PRIVATE_KEY_INVALID exception.\n */\nconst privateKeyInvalid = (): Exception =>\n new Exception(\"The GitHub App's private key isn't an unencrypted RSA private key.\", {\n code: ERRORS.GITHUB_PRIVATE_KEY_INVALID,\n });\n\n/**\n * Reads a GitHub App's private key: an unencrypted RSA key, PEM-encoded as PKCS#1 or PKCS#8.\n * @param pem The key.\n * @returns The key.\n * @throws\n * - GITHUB_PRIVATE_KEY_INVALID: The GitHub App's private key isn't an unencrypted RSA private key.\n */\nconst readPrivateKey = (pem: unknown): KeyObject => {\n const trimmed = typeof pem === 'string' ? pem.trim() : '';\n if (!PRIVATE_KEY_PEM_PATTERN.test(trimmed)) {\n throw privateKeyInvalid();\n }\n let key: KeyObject;\n try {\n key = createPrivateKey({ key: trimmed, format: 'pem' });\n } catch {\n throw privateKeyInvalid();\n }\n if (key.asymmetricKeyType !== 'rsa') {\n throw privateKeyInvalid();\n }\n return key;\n};\n\n/**\n * Encodes a value as JSON in base64url, as a JWT's header and payload are.\n * @param value The value.\n * @returns The encoded value.\n */\nconst encodeJson = (value: unknown): string =>\n Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');\n\n/**\n * Checks that a GitHub App's private key can sign its JWTs: an unencrypted RSA key, PEM-encoded\n * as PKCS#1, the `RSA PRIVATE KEY` GitHub downloads, or PKCS#8, the `PRIVATE KEY` it converts to.\n * Run it at startup, so a bad key fails before the first request. The error never holds the key.\n * @param pem The key.\n * @example\n * import { assertGithubAppPrivateKey } from 'jg-github';\n *\n * declare const privateKey: string;\n *\n * assertGithubAppPrivateKey(privateKey);\n * @throws\n * - GITHUB_PRIVATE_KEY_INVALID: The GitHub App's private key isn't an unencrypted RSA private key.\n */\nexport const assertGithubAppPrivateKey = (pem: string): void => {\n readPrivateKey(pem);\n};\n\n/**\n * Creates the JWT a GitHub App authenticates as itself with, signed with RS256: issued 60 seconds\n * back, for a GitHub clock behind this one, and expiring 540 seconds ahead, inside GitHub's\n * ten-minute limit even with this clock a minute ahead.\n * @param options The App's ID, its private key, and the time.\n * @returns The JWT.\n * @example\n * import { createGithubAppJwt } from 'jg-github';\n *\n * declare const privateKey: string;\n *\n * const jwt = createGithubAppJwt({ appId: 123456, privateKey });\n * @throws\n * - GITHUB_INVALID_ARGUMENT: An argument isn't valid.\n * - GITHUB_PRIVATE_KEY_INVALID: The GitHub App's private key isn't an unencrypted RSA private key.\n */\nexport const createGithubAppJwt = ({\n appId,\n privateKey,\n now = new Date(),\n}: ICreateGithubAppJwtOptions): string => {\n const issuer = typeof appId === 'number' ? String(appId) : appId;\n if (\n typeof issuer !== 'string' ||\n !APP_ID_PATTERN.test(issuer) ||\n (typeof appId === 'number' && !(Number.isSafeInteger(appId) && appId > 0))\n ) {\n throw invalidArgument('appId');\n }\n if (!(now instanceof Date) || Number.isNaN(now.getTime())) {\n throw invalidArgument('now');\n }\n const key = readPrivateKey(privateKey);\n const seconds = Math.floor(now.getTime() / 1000);\n const unsigned = `${encodeJson({ alg: 'RS256', typ: 'JWT' })}.${encodeJson({\n iat: seconds - JWT_CLOCK_SKEW_SECONDS,\n exp: seconds + JWT_LIFETIME_SECONDS,\n iss: issuer,\n })}`;\n return `${unsigned}.${sign('RSA-SHA256', Buffer.from(unsigned, 'utf8'), key).toString('base64url')}`;\n};\n"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// the seconds an App JWT's issue time is set back, for a GitHub clock behind this one
|
|
2
|
+
export const JWT_CLOCK_SKEW_SECONDS = 60;
|
|
3
|
+
// the seconds an App JWT lasts from now: inside GitHub's ten-minute limit, even with a clock a
|
|
4
|
+
// minute ahead of GitHub's
|
|
5
|
+
export const JWT_LIFETIME_SECONDS = 540;
|
|
6
|
+
// the PEM labels of an unencrypted RSA private key: PKCS#1 and PKCS#8
|
|
7
|
+
export const PRIVATE_KEY_PEM_PATTERN = /^-----BEGIN (?:RSA )?PRIVATE KEY-----\r?\n/;
|
|
8
|
+
// a GitHub App's ID, or its client ID, such as 'Iv23liAbCdEf0123'
|
|
9
|
+
export const APP_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
10
|
+
//# sourceMappingURL=constants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.js","sourceRoot":"","sources":["../../src/app-auth/constants.ts"],"names":[],"mappings":"AAAA,sFAAsF;AACtF,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,CAAC;AAEzC,+FAA+F;AAC/F,2BAA2B;AAC3B,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAAG,CAAC;AAExC,sEAAsE;AACtE,MAAM,CAAC,MAAM,uBAAuB,GAAG,4CAA4C,CAAC;AAEpF,kEAAkE;AAClE,MAAM,CAAC,MAAM,cAAc,GAAG,wBAAwB,CAAC","sourcesContent":["// the seconds an App JWT's issue time is set back, for a GitHub clock behind this one\nexport const JWT_CLOCK_SKEW_SECONDS = 60;\n\n// the seconds an App JWT lasts from now: inside GitHub's ten-minute limit, even with a clock a\n// minute ahead of GitHub's\nexport const JWT_LIFETIME_SECONDS = 540;\n\n// the PEM labels of an unencrypted RSA private key: PKCS#1 and PKCS#8\nexport const PRIVATE_KEY_PEM_PATTERN = /^-----BEGIN (?:RSA )?PRIVATE KEY-----\\r?\\n/;\n\n// a GitHub App's ID, or its client ID, such as 'Iv23liAbCdEf0123'\nexport const APP_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/app-auth/index.ts"],"names":[],"mappings":"AAGA,qBAAqB;AACrB,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC","sourcesContent":["// types\nexport type { ICreateGithubAppJwtOptions } from './types.js';\n\n// App authentication\nexport { assertGithubAppPrivateKey, createGithubAppJwt } from './app-auth.js';\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/app-auth/types.ts"],"names":[],"mappings":"","sourcesContent":["// what createGithubAppJwt takes\nexport type ICreateGithubAppJwtOptions = {\n // the App's ID, such as 123456, or its client ID\n appId: string | number;\n\n // the App's private key, PEM-encoded, as GitHub downloads it\n privateKey: string;\n\n // the time the JWT is issued at. Defaults to now\n now?: Date | undefined;\n};\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { ERRORS } from './shared/index.js';
|
|
2
|
+
export { DEFAULT_GITHUB_MAX_RESPONSE_BYTES, DEFAULT_GITHUB_TIMEOUT_SECONDS, GITHUB_API_ORIGIN, GITHUB_API_VERSION, GITHUB_RAW_CONTENT_ORIGIN, type IGithubApiUrlOptions, type IGithubHeadersOptions, type IGithubQuery, type IGithubRawContentLocation, type IGithubRawContentUrlOptions, buildGithubApiUrl, buildGithubRawContentUrl, createGithubHeaders, } from './shared/index.js';
|
|
3
|
+
export { type ICreateGithubAppJwtOptions, assertGithubAppPrivateKey, createGithubAppJwt, } from './app-auth/index.js';
|
|
4
|
+
export { type ICreateInstallationTokenOptions, type IGithubPermissionLevel, type IGithubPermissions, type IGithubRepository, type IGithubRequestOptions, type IInstallationToken, type IInstallationTokenRepositories, createInstallationToken, revokeInstallationToken, } from './installation-token/index.js';
|
|
5
|
+
export { verifyGithubWebhookSignature } from './webhook/index.js';
|
|
6
|
+
export { type IGithubRateLimitDeadlineOptions, type IGithubResponseHeaders, getGithubRateLimitDeadline, } from './rate-limit/index.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// error codes
|
|
2
|
+
export { ERRORS } from './shared/index.js';
|
|
3
|
+
// requests
|
|
4
|
+
export { DEFAULT_GITHUB_MAX_RESPONSE_BYTES, DEFAULT_GITHUB_TIMEOUT_SECONDS, GITHUB_API_ORIGIN, GITHUB_API_VERSION, GITHUB_RAW_CONTENT_ORIGIN, buildGithubApiUrl, buildGithubRawContentUrl, createGithubHeaders, } from './shared/index.js';
|
|
5
|
+
// App authentication
|
|
6
|
+
export { assertGithubAppPrivateKey, createGithubAppJwt, } from './app-auth/index.js';
|
|
7
|
+
// installation tokens
|
|
8
|
+
export { createInstallationToken, revokeInstallationToken, } from './installation-token/index.js';
|
|
9
|
+
// webhooks
|
|
10
|
+
export { verifyGithubWebhookSignature } from './webhook/index.js';
|
|
11
|
+
// rate limits
|
|
12
|
+
export { getGithubRateLimitDeadline, } from './rate-limit/index.js';
|
|
13
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc;AACd,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE3C,WAAW;AACX,OAAO,EACL,iCAAiC,EACjC,8BAA8B,EAC9B,iBAAiB,EACjB,kBAAkB,EAClB,yBAAyB,EAMzB,iBAAiB,EACjB,wBAAwB,EACxB,mBAAmB,GACpB,MAAM,mBAAmB,CAAC;AAE3B,qBAAqB;AACrB,OAAO,EAEL,yBAAyB,EACzB,kBAAkB,GACnB,MAAM,qBAAqB,CAAC;AAE7B,sBAAsB;AACtB,OAAO,EAQL,uBAAuB,EACvB,uBAAuB,GACxB,MAAM,+BAA+B,CAAC;AAEvC,WAAW;AACX,OAAO,EAAE,4BAA4B,EAAE,MAAM,oBAAoB,CAAC;AAElE,cAAc;AACd,OAAO,EAGL,0BAA0B,GAC3B,MAAM,uBAAuB,CAAC","sourcesContent":["// error codes\nexport { ERRORS } from './shared/index.js';\n\n// requests\nexport {\n DEFAULT_GITHUB_MAX_RESPONSE_BYTES,\n DEFAULT_GITHUB_TIMEOUT_SECONDS,\n GITHUB_API_ORIGIN,\n GITHUB_API_VERSION,\n GITHUB_RAW_CONTENT_ORIGIN,\n type IGithubApiUrlOptions,\n type IGithubHeadersOptions,\n type IGithubQuery,\n type IGithubRawContentLocation,\n type IGithubRawContentUrlOptions,\n buildGithubApiUrl,\n buildGithubRawContentUrl,\n createGithubHeaders,\n} from './shared/index.js';\n\n// App authentication\nexport {\n type ICreateGithubAppJwtOptions,\n assertGithubAppPrivateKey,\n createGithubAppJwt,\n} from './app-auth/index.js';\n\n// installation tokens\nexport {\n type ICreateInstallationTokenOptions,\n type IGithubPermissionLevel,\n type IGithubPermissions,\n type IGithubRepository,\n type IGithubRequestOptions,\n type IInstallationToken,\n type IInstallationTokenRepositories,\n createInstallationToken,\n revokeInstallationToken,\n} from './installation-token/index.js';\n\n// webhooks\nexport { verifyGithubWebhookSignature } from './webhook/index.js';\n\n// rate limits\nexport {\n type IGithubRateLimitDeadlineOptions,\n type IGithubResponseHeaders,\n getGithubRateLimitDeadline,\n} from './rate-limit/index.js';\n"]}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IFetchErrorKind } from 'jg-fetch';
|
|
2
|
+
import type { IGithubPermissionLevel } from './types.js';
|
|
3
|
+
export declare const PERMISSION_LEVELS: ReadonlySet<IGithubPermissionLevel>;
|
|
4
|
+
export declare const PERMISSION_NAME_PATTERN: RegExp;
|
|
5
|
+
export declare const REPOSITORY_NAME_PATTERN: RegExp;
|
|
6
|
+
export declare const INSTALLATION_ID_PATTERN: RegExp;
|
|
7
|
+
export declare const IMPLICIT_PERMISSION: {
|
|
8
|
+
readonly name: "metadata";
|
|
9
|
+
readonly level: "read";
|
|
10
|
+
};
|
|
11
|
+
export declare const INVALID_ARGUMENT_KINDS: ReadonlySet<IFetchErrorKind>;
|
|
12
|
+
export declare const RESPONSE_INVALID_KINDS: ReadonlySet<IFetchErrorKind>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// the access levels a permission can ask for
|
|
2
|
+
export const PERMISSION_LEVELS = new Set([
|
|
3
|
+
'read',
|
|
4
|
+
'write',
|
|
5
|
+
'admin',
|
|
6
|
+
]);
|
|
7
|
+
// a permission's name, such as 'contents' or 'pull_requests'
|
|
8
|
+
export const PERMISSION_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
9
|
+
// a repository's name, without its owner
|
|
10
|
+
export const REPOSITORY_NAME_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
|
|
11
|
+
// an installation's ID
|
|
12
|
+
export const INSTALLATION_ID_PATTERN = /^[1-9]\d{0,19}$/;
|
|
13
|
+
// the permission GitHub may grant without being asked, since every token can read metadata
|
|
14
|
+
export const IMPLICIT_PERMISSION = { name: 'metadata', level: 'read' };
|
|
15
|
+
// jg-fetch's failures that mean the request's own options weren't valid, such as a timeout of 0
|
|
16
|
+
export const INVALID_ARGUMENT_KINDS = new Set([
|
|
17
|
+
'INVALID_URL',
|
|
18
|
+
'INVALID_OPTIONS',
|
|
19
|
+
'INVALID_HEADERS',
|
|
20
|
+
'INVALID_BODY',
|
|
21
|
+
]);
|
|
22
|
+
// jg-fetch's failures that mean GitHub's answer couldn't be read
|
|
23
|
+
export const RESPONSE_INVALID_KINDS = new Set([
|
|
24
|
+
'PARSE_ERROR',
|
|
25
|
+
'RESPONSE_TOO_LARGE',
|
|
26
|
+
'RESPONSE_INVALID',
|
|
27
|
+
'ENVELOPE_INVALID',
|
|
28
|
+
'API_ERROR',
|
|
29
|
+
]);
|
|
30
|
+
//# sourceMappingURL=constants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.js","sourceRoot":"","sources":["../../src/installation-token/constants.ts"],"names":[],"mappings":"AAIA,6CAA6C;AAC7C,MAAM,CAAC,MAAM,iBAAiB,GAAwC,IAAI,GAAG,CAAC;IAC5E,MAAM;IACN,OAAO;IACP,OAAO;CACR,CAAC,CAAC;AAEH,6DAA6D;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,mBAAmB,CAAC;AAE3D,yCAAyC;AACzC,MAAM,CAAC,MAAM,uBAAuB,GAAG,yBAAyB,CAAC;AAEjE,uBAAuB;AACvB,MAAM,CAAC,MAAM,uBAAuB,GAAG,iBAAiB,CAAC;AAEzD,2FAA2F;AAC3F,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAW,CAAC;AAEhF,gGAAgG;AAChG,MAAM,CAAC,MAAM,sBAAsB,GAAiC,IAAI,GAAG,CAAC;IAC1E,aAAa;IACb,iBAAiB;IACjB,iBAAiB;IACjB,cAAc;CACf,CAAC,CAAC;AAEH,iEAAiE;AACjE,MAAM,CAAC,MAAM,sBAAsB,GAAiC,IAAI,GAAG,CAAC;IAC1E,aAAa;IACb,oBAAoB;IACpB,kBAAkB;IAClB,kBAAkB;IAClB,WAAW;CACZ,CAAC,CAAC","sourcesContent":["import type { IFetchErrorKind } from 'jg-fetch';\n\nimport type { IGithubPermissionLevel } from './types.js';\n\n// the access levels a permission can ask for\nexport const PERMISSION_LEVELS: ReadonlySet<IGithubPermissionLevel> = new Set([\n 'read',\n 'write',\n 'admin',\n]);\n\n// a permission's name, such as 'contents' or 'pull_requests'\nexport const PERMISSION_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;\n\n// a repository's name, without its owner\nexport const REPOSITORY_NAME_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;\n\n// an installation's ID\nexport const INSTALLATION_ID_PATTERN = /^[1-9]\\d{0,19}$/;\n\n// the permission GitHub may grant without being asked, since every token can read metadata\nexport const IMPLICIT_PERMISSION = { name: 'metadata', level: 'read' } as const;\n\n// jg-fetch's failures that mean the request's own options weren't valid, such as a timeout of 0\nexport const INVALID_ARGUMENT_KINDS: ReadonlySet<IFetchErrorKind> = new Set([\n 'INVALID_URL',\n 'INVALID_OPTIONS',\n 'INVALID_HEADERS',\n 'INVALID_BODY',\n]);\n\n// jg-fetch's failures that mean GitHub's answer couldn't be read\nexport const RESPONSE_INVALID_KINDS: ReadonlySet<IFetchErrorKind> = new Set([\n 'PARSE_ERROR',\n 'RESPONSE_TOO_LARGE',\n 'RESPONSE_INVALID',\n 'ENVELOPE_INVALID',\n 'API_ERROR',\n]);\n"]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Exception } from 'jg-exception';
|
|
2
|
+
import type { FetchException } from 'jg-fetch';
|
|
3
|
+
import type { IScopeMismatch } from './utilities.js';
|
|
4
|
+
/**
|
|
5
|
+
* Builds the exception for an answer from GitHub that isn't in the expected format, without the
|
|
6
|
+
* answer, which can hold the token.
|
|
7
|
+
* @param redact The credentials the request used or received.
|
|
8
|
+
* @param isRevoked Whether the token the answer held was revoked, or undefined without one.
|
|
9
|
+
* @returns The GITHUB_RESPONSE_INVALID exception.
|
|
10
|
+
*/
|
|
11
|
+
export declare const responseInvalid: (redact: readonly string[], isRevoked?: boolean) => Exception;
|
|
12
|
+
/**
|
|
13
|
+
* Builds the exception for a token whose scope isn't the one requested.
|
|
14
|
+
* @param mismatch What differs.
|
|
15
|
+
* @param isRevoked Whether the token was revoked.
|
|
16
|
+
* @param redact The credentials the request used or received.
|
|
17
|
+
* @returns The GITHUB_TOKEN_SCOPE_MISMATCH exception.
|
|
18
|
+
*/
|
|
19
|
+
export declare const scopeMismatch: (mismatch: IScopeMismatch, isRevoked: boolean, redact: readonly string[]) => Exception;
|
|
20
|
+
/**
|
|
21
|
+
* Builds the exception for a token that had expired when it arrived.
|
|
22
|
+
* @param isRevoked Whether the token was revoked.
|
|
23
|
+
* @param redact The credentials the request used or received.
|
|
24
|
+
* @returns The GITHUB_TOKEN_EXPIRED exception.
|
|
25
|
+
*/
|
|
26
|
+
export declare const tokenExpired: (isRevoked: boolean, redact: readonly string[]) => Exception;
|
|
27
|
+
/**
|
|
28
|
+
* Turns jg-fetch's error into jg-github's own, without the cause, whose body can hold a token.
|
|
29
|
+
* @param error jg-fetch's error.
|
|
30
|
+
* @param redact The credentials the request used.
|
|
31
|
+
* @returns The exception.
|
|
32
|
+
*/
|
|
33
|
+
export declare const fromFetchError: (error: FetchException, redact: readonly string[]) => Exception;
|
|
34
|
+
/**
|
|
35
|
+
* Builds the exception for a token GitHub didn't revoke.
|
|
36
|
+
* @param kind jg-fetch's kind of failure, or `UNEXPECTED_STATUS` for an answer other than 204.
|
|
37
|
+
* @param status The status, or null without an answer.
|
|
38
|
+
* @param redact The token.
|
|
39
|
+
* @returns The GITHUB_TOKEN_REVOKE_FAILED exception.
|
|
40
|
+
*/
|
|
41
|
+
export declare const revokeFailed: (kind: string, status: number | null, redact: readonly string[]) => Exception;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Exception } from 'jg-exception';
|
|
2
|
+
import { getGithubRateLimitDeadline } from '../rate-limit/index.js';
|
|
3
|
+
import { ERRORS, invalidArgument, isRecord } from '../shared/index.js';
|
|
4
|
+
import { INVALID_ARGUMENT_KINDS, RESPONSE_INVALID_KINDS } from './constants.js';
|
|
5
|
+
/**
|
|
6
|
+
* Builds the exception for an answer from GitHub that isn't in the expected format, without the
|
|
7
|
+
* answer, which can hold the token.
|
|
8
|
+
* @param redact The credentials the request used or received.
|
|
9
|
+
* @param isRevoked Whether the token the answer held was revoked, or undefined without one.
|
|
10
|
+
* @returns The GITHUB_RESPONSE_INVALID exception.
|
|
11
|
+
*/
|
|
12
|
+
export const responseInvalid = (redact, isRevoked) => new Exception("GitHub's response isn't in the expected format.", {
|
|
13
|
+
code: ERRORS.GITHUB_RESPONSE_INVALID,
|
|
14
|
+
...(isRevoked === undefined ? {} : { data: { isRevoked } }),
|
|
15
|
+
redact,
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Builds the exception for a token whose scope isn't the one requested.
|
|
19
|
+
* @param mismatch What differs.
|
|
20
|
+
* @param isRevoked Whether the token was revoked.
|
|
21
|
+
* @param redact The credentials the request used or received.
|
|
22
|
+
* @returns The GITHUB_TOKEN_SCOPE_MISMATCH exception.
|
|
23
|
+
*/
|
|
24
|
+
export const scopeMismatch = (mismatch, isRevoked, redact) => new Exception("The installation token's scope isn't the one requested.", {
|
|
25
|
+
code: ERRORS.GITHUB_TOKEN_SCOPE_MISMATCH,
|
|
26
|
+
data: { mismatch, isRevoked },
|
|
27
|
+
redact,
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* Builds the exception for a token that had expired when it arrived.
|
|
31
|
+
* @param isRevoked Whether the token was revoked.
|
|
32
|
+
* @param redact The credentials the request used or received.
|
|
33
|
+
* @returns The GITHUB_TOKEN_EXPIRED exception.
|
|
34
|
+
*/
|
|
35
|
+
export const tokenExpired = (isRevoked, redact) => new Exception('The installation token had already expired.', {
|
|
36
|
+
code: ERRORS.GITHUB_TOKEN_EXPIRED,
|
|
37
|
+
data: { isRevoked },
|
|
38
|
+
redact,
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* Describes a failed request from jg-fetch's error, without its body, which can hold a token:
|
|
42
|
+
* the kind of failure, the status, and the rate limit's deadline, when there's one.
|
|
43
|
+
* @param error jg-fetch's error.
|
|
44
|
+
* @returns The failure.
|
|
45
|
+
*/
|
|
46
|
+
const describeFailure = (error) => {
|
|
47
|
+
const body = error.body;
|
|
48
|
+
const hasRateLimitMessage = isRecord(body) && typeof body['message'] === 'string' && /rate limit/i.test(body['message']);
|
|
49
|
+
const deadline = error.status === null || error.headers === null
|
|
50
|
+
? null
|
|
51
|
+
: getGithubRateLimitDeadline({
|
|
52
|
+
status: error.status,
|
|
53
|
+
headers: error.headers,
|
|
54
|
+
retryCount: 0,
|
|
55
|
+
hasRateLimitMessage,
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
kind: error.kind,
|
|
59
|
+
status: error.status,
|
|
60
|
+
rateLimitDeadline: deadline?.toISOString() ?? null,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Turns jg-fetch's error into jg-github's own, without the cause, whose body can hold a token.
|
|
65
|
+
* @param error jg-fetch's error.
|
|
66
|
+
* @param redact The credentials the request used.
|
|
67
|
+
* @returns The exception.
|
|
68
|
+
*/
|
|
69
|
+
export const fromFetchError = (error, redact) => {
|
|
70
|
+
if (INVALID_ARGUMENT_KINDS.has(error.kind)) {
|
|
71
|
+
return invalidArgument('options');
|
|
72
|
+
}
|
|
73
|
+
if (RESPONSE_INVALID_KINDS.has(error.kind)) {
|
|
74
|
+
return responseInvalid(redact);
|
|
75
|
+
}
|
|
76
|
+
return new Exception('The GitHub request failed.', {
|
|
77
|
+
code: ERRORS.GITHUB_REQUEST_FAILED,
|
|
78
|
+
data: describeFailure(error),
|
|
79
|
+
redact,
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Builds the exception for a token GitHub didn't revoke.
|
|
84
|
+
* @param kind jg-fetch's kind of failure, or `UNEXPECTED_STATUS` for an answer other than 204.
|
|
85
|
+
* @param status The status, or null without an answer.
|
|
86
|
+
* @param redact The token.
|
|
87
|
+
* @returns The GITHUB_TOKEN_REVOKE_FAILED exception.
|
|
88
|
+
*/
|
|
89
|
+
export const revokeFailed = (kind, status, redact) => new Exception("The installation token couldn't be revoked.", {
|
|
90
|
+
code: ERRORS.GITHUB_TOKEN_REVOKE_FAILED,
|
|
91
|
+
data: { kind, status },
|
|
92
|
+
redact,
|
|
93
|
+
});
|
|
94
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/installation-token/errors.ts"],"names":[],"mappings":"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","sourcesContent":["import { Exception } from 'jg-exception';\nimport type { FetchException } from 'jg-fetch';\n\nimport { getGithubRateLimitDeadline } from '../rate-limit/index.js';\nimport { ERRORS, invalidArgument, isRecord } from '../shared/index.js';\nimport { INVALID_ARGUMENT_KINDS, RESPONSE_INVALID_KINDS } from './constants.js';\nimport type { IScopeMismatch } from './utilities.js';\n\n// what a request failure's data holds\ntype IRequestFailure = {\n kind: string;\n status: number | null;\n rateLimitDeadline: string | null;\n};\n\n/**\n * Builds the exception for an answer from GitHub that isn't in the expected format, without the\n * answer, which can hold the token.\n * @param redact The credentials the request used or received.\n * @param isRevoked Whether the token the answer held was revoked, or undefined without one.\n * @returns The GITHUB_RESPONSE_INVALID exception.\n */\nexport const responseInvalid = (redact: readonly string[], isRevoked?: boolean): Exception =>\n new Exception(\"GitHub's response isn't in the expected format.\", {\n code: ERRORS.GITHUB_RESPONSE_INVALID,\n ...(isRevoked === undefined ? {} : { data: { isRevoked } }),\n redact,\n });\n\n/**\n * Builds the exception for a token whose scope isn't the one requested.\n * @param mismatch What differs.\n * @param isRevoked Whether the token was revoked.\n * @param redact The credentials the request used or received.\n * @returns The GITHUB_TOKEN_SCOPE_MISMATCH exception.\n */\nexport const scopeMismatch = (\n mismatch: IScopeMismatch,\n isRevoked: boolean,\n redact: readonly string[],\n): Exception =>\n new Exception(\"The installation token's scope isn't the one requested.\", {\n code: ERRORS.GITHUB_TOKEN_SCOPE_MISMATCH,\n data: { mismatch, isRevoked },\n redact,\n });\n\n/**\n * Builds the exception for a token that had expired when it arrived.\n * @param isRevoked Whether the token was revoked.\n * @param redact The credentials the request used or received.\n * @returns The GITHUB_TOKEN_EXPIRED exception.\n */\nexport const tokenExpired = (isRevoked: boolean, redact: readonly string[]): Exception =>\n new Exception('The installation token had already expired.', {\n code: ERRORS.GITHUB_TOKEN_EXPIRED,\n data: { isRevoked },\n redact,\n });\n\n/**\n * Describes a failed request from jg-fetch's error, without its body, which can hold a token:\n * the kind of failure, the status, and the rate limit's deadline, when there's one.\n * @param error jg-fetch's error.\n * @returns The failure.\n */\nconst describeFailure = (error: FetchException): IRequestFailure => {\n const body = error.body;\n const hasRateLimitMessage =\n isRecord(body) && typeof body['message'] === 'string' && /rate limit/i.test(body['message']);\n const deadline =\n error.status === null || error.headers === null\n ? null\n : getGithubRateLimitDeadline({\n status: error.status,\n headers: error.headers,\n retryCount: 0,\n hasRateLimitMessage,\n });\n return {\n kind: error.kind,\n status: error.status,\n rateLimitDeadline: deadline?.toISOString() ?? null,\n };\n};\n\n/**\n * Turns jg-fetch's error into jg-github's own, without the cause, whose body can hold a token.\n * @param error jg-fetch's error.\n * @param redact The credentials the request used.\n * @returns The exception.\n */\nexport const fromFetchError = (error: FetchException, redact: readonly string[]): Exception => {\n if (INVALID_ARGUMENT_KINDS.has(error.kind)) {\n return invalidArgument('options');\n }\n if (RESPONSE_INVALID_KINDS.has(error.kind)) {\n return responseInvalid(redact);\n }\n return new Exception('The GitHub request failed.', {\n code: ERRORS.GITHUB_REQUEST_FAILED,\n data: describeFailure(error),\n redact,\n });\n};\n\n/**\n * Builds the exception for a token GitHub didn't revoke.\n * @param kind jg-fetch's kind of failure, or `UNEXPECTED_STATUS` for an answer other than 204.\n * @param status The status, or null without an answer.\n * @param redact The token.\n * @returns The GITHUB_TOKEN_REVOKE_FAILED exception.\n */\nexport const revokeFailed = (\n kind: string,\n status: number | null,\n redact: readonly string[],\n): Exception =>\n new Exception(\"The installation token couldn't be revoked.\", {\n code: ERRORS.GITHUB_TOKEN_REVOKE_FAILED,\n data: { kind, status },\n redact,\n });\n"]}
|