@theholocron/github-client 1.20.0 → 1.21.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/README.md +34 -0
- package/dist/index.d.mts +29 -1
- package/dist/index.mjs +159 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -90,3 +90,37 @@ A consumer owns what to _do_ with a verified delivery — which event
|
|
|
90
90
|
categories matter, how to normalize them into its own domain shape.
|
|
91
91
|
`@theholocron/sentinel`'s `parseWebhookEvent()` is the reference
|
|
92
92
|
consumer.
|
|
93
|
+
|
|
94
|
+
## GitHub App authentication
|
|
95
|
+
|
|
96
|
+
A signed App JWT, exchanged for a short-lived installation access token,
|
|
97
|
+
exchanged for a ready-to-use `GitHubClient` — Web Crypto only
|
|
98
|
+
(`globalThis.crypto`/`CryptoKey`, no `node:crypto`), so it runs unchanged
|
|
99
|
+
on Cloudflare Workers (no `nodejs_compat` flag needed) and in Node.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { createInstallationClient } from "@theholocron/github-client";
|
|
103
|
+
|
|
104
|
+
const client = await createInstallationClient(
|
|
105
|
+
{ appId: process.env.GITHUB_APP_ID!, privateKey: process.env.GITHUB_APP_PRIVATE_KEY! },
|
|
106
|
+
installationId // from the webhook payload that triggered this — never hardcoded (D10)
|
|
107
|
+
);
|
|
108
|
+
const repo = await client.repos.getRepo("owner/name");
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`privateKey` accepts either PEM format GitHub hands out — PKCS#1 (an
|
|
112
|
+
`RSA PRIVATE KEY`-headered PEM block, the default download) or PKCS#8
|
|
113
|
+
(a plain `PRIVATE KEY`-headered PEM block); `importRsaPrivateKey`
|
|
114
|
+
(internal) wraps a PKCS#1 key in the PKCS#8 DER structure
|
|
115
|
+
`SubtleCrypto.importKey()` requires — Web Crypto only accepts PKCS#8
|
|
116
|
+
directly.
|
|
117
|
+
|
|
118
|
+
Lower-level pieces, if you need to manage the installation token's
|
|
119
|
+
lifetime yourself (it lasts about an hour) rather than fetching one per
|
|
120
|
+
call:
|
|
121
|
+
|
|
122
|
+
- `createAppJWT(creds)` — signs the App-level JWT (not installation-scoped; only good for requesting an installation token, never for calling the REST API directly).
|
|
123
|
+
- `getInstallationAccessToken(creds, installationId)` — exchanges that JWT for `{ token, expiresAt }`.
|
|
124
|
+
|
|
125
|
+
`@theholocron/sentinel`'s webhook handler is the reference consumer —
|
|
126
|
+
one App, N installations across N orgs/accounts, the same registration.
|
package/dist/index.d.mts
CHANGED
|
@@ -255,6 +255,34 @@ interface WorkflowRunFilter {
|
|
|
255
255
|
status?: string;
|
|
256
256
|
}
|
|
257
257
|
//#endregion
|
|
258
|
+
//#region src/app-auth/app-auth.d.ts
|
|
259
|
+
interface GitHubAppCredentials {
|
|
260
|
+
/** The App's numeric id (not the client id). */
|
|
261
|
+
appId: string;
|
|
262
|
+
/** PEM-encoded RSA private key — PKCS#1 (GitHub's default download format) or PKCS#8, either works. */
|
|
263
|
+
privateKey: string;
|
|
264
|
+
}
|
|
265
|
+
interface InstallationAccessToken {
|
|
266
|
+
token: string;
|
|
267
|
+
/** ISO 8601 — installation tokens are short-lived, about an hour. */
|
|
268
|
+
expiresAt: string;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Signs a JWT identifying this App (not a specific installation) — the
|
|
272
|
+
* credential used only to request an installation access token, never
|
|
273
|
+
* to call the REST API directly.
|
|
274
|
+
*/
|
|
275
|
+
declare function createAppJWT(creds: GitHubAppCredentials, now?: number): Promise<string>;
|
|
276
|
+
/**
|
|
277
|
+
* Exchanges an App JWT for an installation access token — the credential
|
|
278
|
+
* that actually authenticates REST calls, scoped to exactly one
|
|
279
|
+
* installation (D10: never a hardcoded org — the caller supplies which
|
|
280
|
+
* installation, from the webhook payload that triggered this).
|
|
281
|
+
*/
|
|
282
|
+
declare function getInstallationAccessToken(creds: GitHubAppCredentials, installationId: number, opts?: Pick<GitHubClientOptions, "baseUrl" | "fetch">): Promise<InstallationAccessToken>;
|
|
283
|
+
/** The whole flow in one call — an already-authenticated `GitHubClient` scoped to one installation. */
|
|
284
|
+
declare function createInstallationClient(creds: GitHubAppCredentials, installationId: number, opts?: Pick<GitHubClientOptions, "baseUrl" | "fetch">): Promise<GitHubClient>;
|
|
285
|
+
//#endregion
|
|
258
286
|
//#region src/webhooks/webhooks.d.ts
|
|
259
287
|
/**
|
|
260
288
|
* GitHub's own webhook mechanics — signature verification and header/
|
|
@@ -429,4 +457,4 @@ declare function createGitHubClient(opts: GitHubClientOptions): {
|
|
|
429
457
|
};
|
|
430
458
|
type GitHubClient = ReturnType<typeof createGitHubClient>;
|
|
431
459
|
//#endregion
|
|
432
|
-
export { type CheckRunConclusion, type CheckRunOutput, type CheckRunStatus, type CodeScanningSetupResult, type CreateCheckRunInput, type CreatePagesPayload, type CreatePullInput, type GitBlob, type GitCommit, type GitContents, type GitHubCheckRun, GitHubClient, type GitHubClientOptions, type GitHubContents, type GitHubEnvironment, type GitHubInstallationWebhookPayload, type GitHubIssue, type GitHubLabel, type GitHubMilestone, type GitHubPages, type GitHubPublicKey, type GitHubPullRequest, type GitHubPullRequestWebhookPayload, type GitHubPushWebhookPayload, type GitHubRepo, type GitHubRuleset, type GitHubUser, type GitHubWebhookHeaders, type GitHubWorkflowRun, type GitPull, type GitRef, type GitTree, type GitTreeItem, type IssueSearchParams, type PagesBuildStatus, type PagesBuildType, type SecretScope, type TeamPermission, type UpdatePagesPayload, type WorkflowRunFilter, createGitHubClient, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
|
|
460
|
+
export { type CheckRunConclusion, type CheckRunOutput, type CheckRunStatus, type CodeScanningSetupResult, type CreateCheckRunInput, type CreatePagesPayload, type CreatePullInput, type GitBlob, type GitCommit, type GitContents, type GitHubAppCredentials, type GitHubCheckRun, GitHubClient, type GitHubClientOptions, type GitHubContents, type GitHubEnvironment, type GitHubInstallationWebhookPayload, type GitHubIssue, type GitHubLabel, type GitHubMilestone, type GitHubPages, type GitHubPublicKey, type GitHubPullRequest, type GitHubPullRequestWebhookPayload, type GitHubPushWebhookPayload, type GitHubRepo, type GitHubRuleset, type GitHubUser, type GitHubWebhookHeaders, type GitHubWorkflowRun, type GitPull, type GitRef, type GitTree, type GitTreeItem, type InstallationAccessToken, type IssueSearchParams, type PagesBuildStatus, type PagesBuildType, type SecretScope, type TeamPermission, type UpdatePagesPayload, type WorkflowRunFilter, createAppJWT, createGitHubClient, createInstallationClient, getInstallationAccessToken, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
|
package/dist/index.mjs
CHANGED
|
@@ -353,6 +353,164 @@ function workflows(rest) {
|
|
|
353
353
|
};
|
|
354
354
|
}
|
|
355
355
|
//#endregion
|
|
356
|
+
//#region src/app-auth/pem.ts
|
|
357
|
+
/**
|
|
358
|
+
* PEM/DER handling for GitHub App private keys — Web Crypto only (no
|
|
359
|
+
* `node:crypto`), so this runs unchanged on Cloudflare Workers (no
|
|
360
|
+
* `nodejs_compat` needed) and in Node.
|
|
361
|
+
*
|
|
362
|
+
* GitHub hands out App private keys in PKCS#1 ("-----BEGIN RSA PRIVATE
|
|
363
|
+
* KEY-----") by default; `SubtleCrypto.importKey("pkcs8", …)` only
|
|
364
|
+
* accepts PKCS#8 ("-----BEGIN PRIVATE KEY-----"). `pkcs1ToPkcs8` wraps a
|
|
365
|
+
* PKCS#1 `RSAPrivateKey` DER in the fixed `PrivateKeyInfo` DER structure
|
|
366
|
+
* PKCS#8 requires — a fixed `AlgorithmIdentifier` prefix for
|
|
367
|
+
* `rsaEncryption` (OID 1.2.840.113549.1.1.1) around the unchanged
|
|
368
|
+
* PKCS#1 bytes. Verified against a real generated key pair: imported,
|
|
369
|
+
* signed, and checked against `node:crypto`'s `verify()` on the
|
|
370
|
+
* original key's public half.
|
|
371
|
+
*/
|
|
372
|
+
function pemToDer(pem) {
|
|
373
|
+
const base64 = pem.replace(/-----BEGIN [^-]+-----/, "").replace(/-----END [^-]+-----/, "").replace(/\s/g, "");
|
|
374
|
+
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
375
|
+
}
|
|
376
|
+
function concat(...arrays) {
|
|
377
|
+
const total = arrays.reduce((sum, a) => sum + a.length, 0);
|
|
378
|
+
const out = new Uint8Array(total);
|
|
379
|
+
let offset = 0;
|
|
380
|
+
for (const a of arrays) {
|
|
381
|
+
out.set(a, offset);
|
|
382
|
+
offset += a.length;
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
function encodeDerLength(len) {
|
|
387
|
+
/* istanbul ignore next -- see comment above */
|
|
388
|
+
if (len < 128) return new Uint8Array([len]);
|
|
389
|
+
const bytes = [];
|
|
390
|
+
let n = len;
|
|
391
|
+
while (n > 0) {
|
|
392
|
+
bytes.unshift(n & 255);
|
|
393
|
+
n >>= 8;
|
|
394
|
+
}
|
|
395
|
+
return new Uint8Array([128 | bytes.length, ...bytes]);
|
|
396
|
+
}
|
|
397
|
+
function derEncode(tag, content) {
|
|
398
|
+
return concat(new Uint8Array([tag]), encodeDerLength(content.length), content);
|
|
399
|
+
}
|
|
400
|
+
/** SEQUENCE { OID rsaEncryption, NULL } — the fixed PKCS#8 AlgorithmIdentifier for RSA. */
|
|
401
|
+
const RSA_ALGORITHM_IDENTIFIER = new Uint8Array([
|
|
402
|
+
48,
|
|
403
|
+
13,
|
|
404
|
+
6,
|
|
405
|
+
9,
|
|
406
|
+
42,
|
|
407
|
+
134,
|
|
408
|
+
72,
|
|
409
|
+
134,
|
|
410
|
+
247,
|
|
411
|
+
13,
|
|
412
|
+
1,
|
|
413
|
+
1,
|
|
414
|
+
1,
|
|
415
|
+
5,
|
|
416
|
+
0
|
|
417
|
+
]);
|
|
418
|
+
function pkcs1ToPkcs8(pkcs1) {
|
|
419
|
+
const version = new Uint8Array([
|
|
420
|
+
2,
|
|
421
|
+
1,
|
|
422
|
+
0
|
|
423
|
+
]);
|
|
424
|
+
const octetString = derEncode(4, pkcs1);
|
|
425
|
+
return derEncode(48, concat(version, RSA_ALGORITHM_IDENTIFIER, octetString));
|
|
426
|
+
}
|
|
427
|
+
/** Imports a PEM-encoded RSA private key — PKCS#1 or PKCS#8, either works — for RS256 signing. */
|
|
428
|
+
async function importRsaPrivateKey(pem) {
|
|
429
|
+
const der = pemToDer(pem);
|
|
430
|
+
const pkcs8Der = (pem.includes("BEGIN RSA PRIVATE KEY") ? pkcs1ToPkcs8(der) : der).slice();
|
|
431
|
+
return crypto.subtle.importKey("pkcs8", pkcs8Der, {
|
|
432
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
433
|
+
hash: "SHA-256"
|
|
434
|
+
}, false, ["sign"]);
|
|
435
|
+
}
|
|
436
|
+
/** Base64url (no padding) — the encoding both JWT segments and the signature use. */
|
|
437
|
+
function base64UrlEncode(bytes) {
|
|
438
|
+
let binary = "";
|
|
439
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
440
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
441
|
+
}
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/app-auth/app-auth.ts
|
|
444
|
+
/**
|
|
445
|
+
* GitHub App authentication — a signed App JWT, exchanged for a
|
|
446
|
+
* short-lived installation access token, exchanged for a ready-to-use
|
|
447
|
+
* `GitHubClient`. Web Crypto only (see `pem.ts`), so this runs unchanged
|
|
448
|
+
* on Cloudflare Workers and in Node — no vendor-specific JWT library, no
|
|
449
|
+
* `nodejs_compat` flag.
|
|
450
|
+
*
|
|
451
|
+
* https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app
|
|
452
|
+
* https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
|
|
453
|
+
*/
|
|
454
|
+
const CLOCK_DRIFT_BUFFER_SECONDS = 60;
|
|
455
|
+
/** GitHub rejects an App JWT with more than 10 minutes between iat and exp. */
|
|
456
|
+
const JWT_LIFETIME_SECONDS = 600;
|
|
457
|
+
/**
|
|
458
|
+
* Signs a JWT identifying this App (not a specific installation) — the
|
|
459
|
+
* credential used only to request an installation access token, never
|
|
460
|
+
* to call the REST API directly.
|
|
461
|
+
*/
|
|
462
|
+
async function createAppJWT(creds, now = Date.now()) {
|
|
463
|
+
const key = await importRsaPrivateKey(creds.privateKey);
|
|
464
|
+
const iat = Math.floor(now / 1e3) - CLOCK_DRIFT_BUFFER_SECONDS;
|
|
465
|
+
const exp = iat + JWT_LIFETIME_SECONDS;
|
|
466
|
+
const signingInput = `${base64UrlEncode(new TextEncoder().encode(JSON.stringify({
|
|
467
|
+
alg: "RS256",
|
|
468
|
+
typ: "JWT"
|
|
469
|
+
})))}.${base64UrlEncode(new TextEncoder().encode(JSON.stringify({
|
|
470
|
+
iat,
|
|
471
|
+
exp,
|
|
472
|
+
iss: creds.appId
|
|
473
|
+
})))}`;
|
|
474
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput));
|
|
475
|
+
return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Exchanges an App JWT for an installation access token — the credential
|
|
479
|
+
* that actually authenticates REST calls, scoped to exactly one
|
|
480
|
+
* installation (D10: never a hardcoded org — the caller supplies which
|
|
481
|
+
* installation, from the webhook payload that triggered this).
|
|
482
|
+
*/
|
|
483
|
+
async function getInstallationAccessToken(creds, installationId, opts = {}) {
|
|
484
|
+
const jwt = await createAppJWT(creds);
|
|
485
|
+
const baseUrl = opts.baseUrl ?? "https://api.github.com";
|
|
486
|
+
const res = await (opts.fetch ?? globalThis.fetch)(`${baseUrl}/app/installations/${installationId}/access_tokens`, {
|
|
487
|
+
method: "POST",
|
|
488
|
+
headers: {
|
|
489
|
+
authorization: `Bearer ${jwt}`,
|
|
490
|
+
accept: "application/vnd.github+json",
|
|
491
|
+
"x-github-api-version": "2022-11-28"
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
if (!res.ok) {
|
|
495
|
+
const body = await res.text().catch(() => "");
|
|
496
|
+
throw new ProviderApiError(`GitHub POST /app/installations/${installationId}/access_tokens → ${res.status}`, res.status, body);
|
|
497
|
+
}
|
|
498
|
+
const json = await res.json();
|
|
499
|
+
return {
|
|
500
|
+
token: json.token,
|
|
501
|
+
expiresAt: json.expires_at
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
/** The whole flow in one call — an already-authenticated `GitHubClient` scoped to one installation. */
|
|
505
|
+
async function createInstallationClient(creds, installationId, opts = {}) {
|
|
506
|
+
const { token } = await getInstallationAccessToken(creds, installationId, opts);
|
|
507
|
+
return createGitHubClient({
|
|
508
|
+
token,
|
|
509
|
+
baseUrl: opts.baseUrl,
|
|
510
|
+
fetch: opts.fetch
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
//#endregion
|
|
356
514
|
//#region src/webhooks/webhooks.ts
|
|
357
515
|
/**
|
|
358
516
|
* GitHub's own webhook mechanics — signature verification and header/
|
|
@@ -423,4 +581,4 @@ function createGitHubClient(opts) {
|
|
|
423
581
|
};
|
|
424
582
|
}
|
|
425
583
|
//#endregion
|
|
426
|
-
export { createGitHubClient, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
|
|
584
|
+
export { createAppJWT, createGitHubClient, createInstallationClient, getInstallationAccessToken, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theholocron/github-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "A TypeScript client for the GitHub REST API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@theholocron/observability": "^0.3.0",
|
|
36
|
-
"@theholocron/http-client": "^1.
|
|
36
|
+
"@theholocron/http-client": "^1.21.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@theholocron/cli": "5.0.0-alpha.3",
|