@effected/github 0.15.1 → 0.17.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/ActionsOidc.js +180 -0
- package/CheckRun.js +87 -19
- package/GitHubApp.js +253 -35
- package/GitHubClient.js +52 -3
- package/GitHubContent.js +10 -2
- package/GitHubError.js +50 -20
- package/InstallationTokenStore.js +51 -0
- package/README.md +1 -1
- package/Repo.js +16 -1
- package/WorkflowDispatch.js +91 -23
- package/index.d.ts +683 -28
- package/index.js +5 -3
- package/internal/crypto.js +1 -1
- package/package.json +3 -3
package/GitHubApp.js
CHANGED
|
@@ -2,8 +2,9 @@ import { GitHubError } from "./GitHubError.js";
|
|
|
2
2
|
import { GitHubGraphQLError } from "./GraphQL.js";
|
|
3
3
|
import { GitHubClient, makeClientShape } from "./GitHubClient.js";
|
|
4
4
|
import { numericId } from "./internal/ids.js";
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
5
|
+
import { InstallationTokenStore } from "./InstallationTokenStore.js";
|
|
6
|
+
import { Jwt, JwtKey } from "@effected/jwt";
|
|
7
|
+
import { Cause, Clock, Context, DateTime, Duration, Effect, Layer, Option, Redacted, Ref, Schema, Semaphore, Stream } from "effect";
|
|
7
8
|
|
|
8
9
|
//#region src/GitHubApp.ts
|
|
9
10
|
/**
|
|
@@ -80,7 +81,7 @@ var InstallationToken = class extends Schema.Class("InstallationToken")({
|
|
|
80
81
|
* rather than answering 401 mid-request.
|
|
81
82
|
*/
|
|
82
83
|
isExpired(nowMillis, skew = DEFAULT_SKEW) {
|
|
83
|
-
return DateTime.toEpochMillis(this.expiresAt)
|
|
84
|
+
return isSpent(DateTime.toEpochMillis(this.expiresAt), nowMillis, skew);
|
|
84
85
|
}
|
|
85
86
|
/** The committer identity a commit made with this token should carry. */
|
|
86
87
|
botIdentity() {
|
|
@@ -92,6 +93,8 @@ var InstallationToken = class extends Schema.Class("InstallationToken")({
|
|
|
92
93
|
};
|
|
93
94
|
/** Re-mint a minute before GitHub would start refusing the token. */
|
|
94
95
|
const DEFAULT_SKEW = Duration.seconds(60);
|
|
96
|
+
/** Whether a credential expiring at `expiresAtMillis` should be replaced at `nowMillis`. */
|
|
97
|
+
const isSpent = (expiresAtMillis, nowMillis, skew = DEFAULT_SKEW) => expiresAtMillis - Duration.toMillis(skew) <= nowMillis;
|
|
95
98
|
/**
|
|
96
99
|
* Who a bot commits as.
|
|
97
100
|
*
|
|
@@ -160,16 +163,52 @@ var AppIdentity = class extends Schema.Class("AppIdentity")({
|
|
|
160
163
|
});
|
|
161
164
|
}
|
|
162
165
|
};
|
|
166
|
+
/** `suspended_at` as GitHub sends it: an ISO string, or `null` while active. */
|
|
167
|
+
const SuspendedAt = Schema.OptionFromNullOr(Schema.DateTimeUtcFromString);
|
|
168
|
+
/**
|
|
169
|
+
* One optional field read leniently: `{ [key]: decoded }` when the value
|
|
170
|
+
* decodes, `{}` otherwise. An absent key reads as `undefined`, which none of
|
|
171
|
+
* the schemas this is called with accepts, so absence and an unreadable value
|
|
172
|
+
* alike omit the field rather than failing the installation.
|
|
173
|
+
*/
|
|
174
|
+
const readField = (key, schema, value) => {
|
|
175
|
+
const decoded = Schema.decodeUnknownOption(schema)(value);
|
|
176
|
+
return Option.isSome(decoded) ? { [key]: decoded.value } : {};
|
|
177
|
+
};
|
|
163
178
|
/**
|
|
164
179
|
* One installation of the app.
|
|
165
180
|
*
|
|
181
|
+
* @remarks
|
|
182
|
+
* Every field but `id` is filled only when GitHub's response carries it and
|
|
183
|
+
* it reads cleanly; an unreadable field is omitted rather than failing the
|
|
184
|
+
* listing. A test double built with `Installation.make({ id })` stays valid.
|
|
185
|
+
* An enterprise account carries `accountId` and `accountType` without a
|
|
186
|
+
* login `account`. Encodable:
|
|
187
|
+
* the dates encode to ISO strings and `suspendedAt` to an ISO string or
|
|
188
|
+
* `null`, the same shape GitHub sends.
|
|
189
|
+
*
|
|
166
190
|
* @public
|
|
167
191
|
*/
|
|
168
192
|
var Installation = class extends Schema.Class("Installation")({
|
|
169
193
|
/** The installation id, which is what a token is minted against. */
|
|
170
194
|
id: Schema.Int,
|
|
171
|
-
/** The account the app is installed on, when GitHub reported one. */
|
|
172
|
-
account: Schema.optionalKey(Schema.String)
|
|
195
|
+
/** The account the app is installed on (its login), when GitHub reported one. */
|
|
196
|
+
account: Schema.optionalKey(Schema.String),
|
|
197
|
+
/** The account kind, e.g. "Organization" or "User"; open-ended, because GitHub adds kinds. */
|
|
198
|
+
accountType: Schema.optionalKey(Schema.String),
|
|
199
|
+
/**
|
|
200
|
+
* The account's numeric id.
|
|
201
|
+
*
|
|
202
|
+
* @remarks
|
|
203
|
+
* Not paired with `account`: an enterprise installation carries
|
|
204
|
+
* `accountId` (and may carry `accountType`) but has no login, so `account`
|
|
205
|
+
* is absent.
|
|
206
|
+
*/
|
|
207
|
+
accountId: Schema.optionalKey(Schema.Int),
|
|
208
|
+
/** When the installation was suspended; `Option.none()` when it is active. */
|
|
209
|
+
suspendedAt: Schema.optionalKey(SuspendedAt),
|
|
210
|
+
/** When GitHub last changed the installation. */
|
|
211
|
+
updatedAt: Schema.optionalKey(Schema.DateTimeUtcFromString)
|
|
173
212
|
}) {};
|
|
174
213
|
/**
|
|
175
214
|
* GitHub App authentication: mint, revoke and identify.
|
|
@@ -187,8 +226,8 @@ var Installation = class extends Schema.Class("Installation")({
|
|
|
187
226
|
* already — `@effected/workspaces` ships `localExecLayer`, which builds
|
|
188
227
|
* `@effected/commands`' service, for the same reason.
|
|
189
228
|
*
|
|
190
|
-
* The JWT signer is
|
|
191
|
-
*
|
|
229
|
+
* The JWT signer is `@effected/jwt` — WebCrypto RS256 with no runtime
|
|
230
|
+
* dependencies, so App auth runs on any runtime that has `crypto.subtle`.
|
|
192
231
|
*
|
|
193
232
|
* @example
|
|
194
233
|
* ```ts
|
|
@@ -243,7 +282,135 @@ var GitHubApp = class GitHubApp extends Context.Service()("@effected/github/GitH
|
|
|
243
282
|
* authorization failure, and widening every method's error channel to say so
|
|
244
283
|
* would tax every caller for a case only this layer can produce.
|
|
245
284
|
*/
|
|
246
|
-
static clientLayer = (request, options = {}) => Layer.effect(GitHubClient, Effect.flatMap(GitHubApp, (app) => makeRotatingClient(app
|
|
285
|
+
static clientLayer = (request, options = {}) => Layer.effect(GitHubClient, Effect.flatMap(GitHubApp, (app) => makeRotatingClient(Effect.map(app.token(request), (minted) => ({
|
|
286
|
+
token: minted.token,
|
|
287
|
+
expiresAtMillis: DateTime.toEpochMillis(minted.expiresAt)
|
|
288
|
+
})), app.revoke, options, "GitHubApp.clientLayer"))).pipe(Layer.provide(GitHubApp.layerWith(options)));
|
|
289
|
+
/**
|
|
290
|
+
* A {@link GitHubClient} authenticated as the app itself, with an App JWT.
|
|
291
|
+
*
|
|
292
|
+
* @remarks
|
|
293
|
+
* An App JWT authenticates only the app-level routes: `/app` and everything
|
|
294
|
+
* under `/app/*` (the app's installations and their token mint, its webhook
|
|
295
|
+
* configuration and deliveries), plus the three installation lookups that
|
|
296
|
+
* require a JWT: `GET /repos/{owner}/{repo}/installation`,
|
|
297
|
+
* `GET /orgs/{org}/installation` and `GET /users/{username}/installation`.
|
|
298
|
+
* Installation-scoped routes (a repository's contents, issues, pulls and the
|
|
299
|
+
* rest) answer 401 to it; reach those through {@link GitHubApp.clientLayer}
|
|
300
|
+
* with an installation token instead.
|
|
301
|
+
*
|
|
302
|
+
* The motivating use is a webhook redelivery sweep, which lists recent
|
|
303
|
+
* deliveries with `GET /app/hook/deliveries` and redelivers a failed one
|
|
304
|
+
* with `POST /app/hook/deliveries/{delivery_id}/attempts`.
|
|
305
|
+
*
|
|
306
|
+
* The JWT is signed locally, never fetched, so building the layer makes no
|
|
307
|
+
* request; a key that will not sign fails construction with
|
|
308
|
+
* `GitHubAppError { kind: "jwt" }`. A JWT lives nine minutes and is
|
|
309
|
+
* re-signed a minute before it expires, so a long-running sweep keeps
|
|
310
|
+
* authenticating; calls in between reuse it. It cannot be revoked, so
|
|
311
|
+
* release does nothing. A later signing failure surfaces to the caller as
|
|
312
|
+
* `GitHubError { kind: "unauthorized" }` carrying the `GitHubAppError` as
|
|
313
|
+
* its cause, as with {@link GitHubApp.clientLayer}.
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* ```ts
|
|
317
|
+
* import { GitHubApp, GitHubClient } from "@effected/github";
|
|
318
|
+
* import { Effect, Redacted } from "effect";
|
|
319
|
+
*
|
|
320
|
+
* const sweep = Effect.gen(function* () {
|
|
321
|
+
* const client = yield* GitHubClient;
|
|
322
|
+
* const deliveries = yield* client.request("GET /app/hook/deliveries", { per_page: 100 });
|
|
323
|
+
* for (const delivery of deliveries) {
|
|
324
|
+
* if (delivery.status_code >= 400) {
|
|
325
|
+
* yield* client.request("POST /app/hook/deliveries/{delivery_id}/attempts", {
|
|
326
|
+
* // octokit types a delivery id as number | bigint; the route takes a number.
|
|
327
|
+
* delivery_id: Number(delivery.id),
|
|
328
|
+
* });
|
|
329
|
+
* }
|
|
330
|
+
* }
|
|
331
|
+
* });
|
|
332
|
+
*
|
|
333
|
+
* const layer = GitHubApp.appClientLayer({
|
|
334
|
+
* appId: "12345",
|
|
335
|
+
* privateKey: Redacted.make("-----BEGIN RSA PRIVATE KEY-----\n..."),
|
|
336
|
+
* });
|
|
337
|
+
*
|
|
338
|
+
* Effect.runPromise(Effect.provide(sweep, layer));
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
static appClientLayer = (credentials, options = {}) => Layer.effect(GitHubClient, makeRotatingClient(mintJwt(credentials), () => Effect.void, options, "GitHubApp.appClientLayer"));
|
|
342
|
+
/**
|
|
343
|
+
* An installation token, reused from an {@link InstallationTokenStore}
|
|
344
|
+
* while it has `margin` (five minutes by default) left to live.
|
|
345
|
+
*
|
|
346
|
+
* @remarks
|
|
347
|
+
* For a program that authenticates per request scope (a Worker handling a
|
|
348
|
+
* webhook, say) and would otherwise mint a fresh token every time. The store
|
|
349
|
+
* is keyed by installation id. A stored value that will not decode, or a
|
|
350
|
+
* token within `margin` of expiry, is a miss: the token is minted with
|
|
351
|
+
* {@link GitHubAppShape.token} and written back with a TTL of its expiry
|
|
352
|
+
* minus `margin`. A token minted with less than `margin` to live is returned
|
|
353
|
+
* but not stored.
|
|
354
|
+
*
|
|
355
|
+
* The store never fails this call: a `get` that fails or dies is a miss and
|
|
356
|
+
* a `set` that fails or dies is ignored. Only interruption propagates.
|
|
357
|
+
*
|
|
358
|
+
* **A cached token is never revoked**: another scope or isolate may be using
|
|
359
|
+
* it. It expires on its own, within the hour.
|
|
360
|
+
*
|
|
361
|
+
* Two concurrent calls that both miss will both mint, and the later write
|
|
362
|
+
* wins. That is expected: the store may span isolates, so the kit cannot
|
|
363
|
+
* lock it, and an extra token costs one call and expires on its own.
|
|
364
|
+
*
|
|
365
|
+
* The encoded value written to the store contains the raw token, so
|
|
366
|
+
* encrypting it at rest is the store's job.
|
|
367
|
+
*/
|
|
368
|
+
static cachedToken = (request) => cachedTokenFor(request);
|
|
369
|
+
/**
|
|
370
|
+
* A {@link GitHubClient} authenticated with {@link GitHubApp.cachedToken}.
|
|
371
|
+
*
|
|
372
|
+
* @remarks
|
|
373
|
+
* Built per request scope: provide it where the request is handled, over a
|
|
374
|
+
* `GitHubApp` and an `InstallationTokenStore` provided once at the edge.
|
|
375
|
+
* Unlike {@link GitHubApp.clientLayer} it **never revokes** the token, not
|
|
376
|
+
* even on release, because the token is shared through the store, and it
|
|
377
|
+
* does not rotate, so set `margin` longer than the scope's work can take;
|
|
378
|
+
* the token is good for at least `margin` unless GitHub issued it with less
|
|
379
|
+
* (see {@link CachedTokenRequest.margin}).
|
|
380
|
+
*
|
|
381
|
+
* `options` configures this client's transport only, not the mint: the
|
|
382
|
+
* token is minted by whichever `GitHubApp` the edge provides. On GitHub
|
|
383
|
+
* Enterprise, build that with the same API root, for example
|
|
384
|
+
* `GitHubApp.layerWith({ baseUrl })`, or the mint goes to github.com.
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* ```ts
|
|
388
|
+
* import { GitHubApp, GitHubClient, InstallationTokenStore } from "@effected/github";
|
|
389
|
+
* import { Effect, Layer, Redacted } from "effect";
|
|
390
|
+
*
|
|
391
|
+
* const handle = (installationId: number) =>
|
|
392
|
+
* Effect.flatMap(GitHubClient, (client) =>
|
|
393
|
+
* client.request("GET /repos/{owner}/{repo}", { owner: "acme", repo: "widgets" }),
|
|
394
|
+
* ).pipe(
|
|
395
|
+
* Effect.provide(
|
|
396
|
+
* GitHubApp.cachedClientLayer({
|
|
397
|
+
* appId: "12345",
|
|
398
|
+
* privateKey: Redacted.make("-----BEGIN RSA PRIVATE KEY-----\n..."),
|
|
399
|
+
* installationId,
|
|
400
|
+
* }),
|
|
401
|
+
* ),
|
|
402
|
+
* );
|
|
403
|
+
*
|
|
404
|
+
* // Once, at the edge. On GitHub Enterprise, use GitHubApp.layerWith({ baseUrl }).
|
|
405
|
+
* const Live = Layer.mergeAll(GitHubApp.layer, InstallationTokenStore.layerMemory);
|
|
406
|
+
*
|
|
407
|
+
* Effect.runPromise(Effect.provide(handle(42), Live));
|
|
408
|
+
* ```
|
|
409
|
+
*/
|
|
410
|
+
static cachedClientLayer = (request, options = {}) => Layer.unwrap(Effect.map(cachedTokenFor(request), (cached) => GitHubClient.layerFromToken({
|
|
411
|
+
...options,
|
|
412
|
+
token: cached.token.token
|
|
413
|
+
})));
|
|
247
414
|
/** An in-memory double; unstubbed members die naming themselves. */
|
|
248
415
|
static makeTest = (overrides = {}) => ({
|
|
249
416
|
token: overrides.token ?? (() => unstubbed("token")),
|
|
@@ -255,21 +422,61 @@ var GitHubApp = class GitHubApp extends Context.Service()("@effected/github/GitH
|
|
|
255
422
|
/** {@link GitHubApp.makeTest} behind a `Layer`. */
|
|
256
423
|
static layerTest = (overrides = {}) => Layer.succeed(GitHubApp, GitHubApp.makeTest(overrides));
|
|
257
424
|
};
|
|
425
|
+
/** The cached-token margin when a request names none. */
|
|
426
|
+
const DEFAULT_CACHE_MARGIN = Duration.minutes(5);
|
|
427
|
+
/** `InstallationToken` as the JSON string an {@link InstallationTokenStore} holds. */
|
|
428
|
+
const StoredToken = Schema.fromJsonString(InstallationToken);
|
|
429
|
+
/** A store call that can never fail its caller: failures and defects become `fallback`; interruption propagates. */
|
|
430
|
+
const swallowStore = (effect, fallback) => Effect.catchCause(effect, (cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.succeed(fallback));
|
|
431
|
+
const cachedTokenFor = Effect.fn("GitHubApp.cachedToken")(function* (request) {
|
|
432
|
+
const input = request.margin ?? DEFAULT_CACHE_MARGIN;
|
|
433
|
+
const margin = typeof input === "number" && !Number.isFinite(input) ? Option.none() : Option.filter(Duration.fromInput(input), (duration) => Duration.isFinite(duration) && !Duration.isNegative(duration));
|
|
434
|
+
if (Option.isNone(margin)) return yield* GitHubAppError.of("token", "the cache margin must be a finite, non-negative duration");
|
|
435
|
+
const app = yield* GitHubApp;
|
|
436
|
+
const store = yield* InstallationTokenStore;
|
|
437
|
+
const stored = yield* swallowStore(store.get(request.installationId), Option.none());
|
|
438
|
+
if (Option.isSome(stored)) {
|
|
439
|
+
const decoded = yield* Effect.option(Schema.decodeUnknownEffect(StoredToken)(stored.value));
|
|
440
|
+
const now = yield* Clock.currentTimeMillis;
|
|
441
|
+
if (Option.isSome(decoded) && decoded.value.installationId === request.installationId && !decoded.value.isExpired(now, margin.value)) return {
|
|
442
|
+
token: decoded.value,
|
|
443
|
+
source: "cached"
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const minted = yield* app.token(request);
|
|
447
|
+
const now = yield* Clock.currentTimeMillis;
|
|
448
|
+
const ttlMillis = DateTime.toEpochMillis(minted.expiresAt) - Duration.toMillis(margin.value) - now;
|
|
449
|
+
if (ttlMillis > 0) {
|
|
450
|
+
const encoded = yield* Effect.option(Schema.encodeUnknownEffect(StoredToken)(minted));
|
|
451
|
+
if (Option.isSome(encoded)) yield* swallowStore(store.set(request.installationId, encoded.value, Duration.millis(ttlMillis)), void 0);
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
token: minted,
|
|
455
|
+
source: "minted"
|
|
456
|
+
};
|
|
457
|
+
});
|
|
258
458
|
const unstubbed = (member) => {
|
|
259
459
|
throw new Error(`GitHubApp.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
260
460
|
};
|
|
261
|
-
/** Mint an app JWT
|
|
262
|
-
const mintJwt = (credentials) => Effect.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
461
|
+
/** Mint an app JWT: iat 60 s in the past (clock drift), exp 9 minutes after now (GitHub caps at 10). */
|
|
462
|
+
const mintJwt = (credentials) => Effect.gen(function* () {
|
|
463
|
+
const key = yield* JwtKey.fromPkcs8Pem(credentials.privateKey, { alg: "RS256" });
|
|
464
|
+
const now = Math.floor((yield* Clock.currentTimeMillis) / 1e3);
|
|
465
|
+
const exp = now + 540;
|
|
466
|
+
const token = yield* Jwt.sign({
|
|
467
|
+
iat: now - 60,
|
|
468
|
+
exp,
|
|
469
|
+
iss: credentials.appId
|
|
470
|
+
}, key);
|
|
471
|
+
return {
|
|
472
|
+
token: Redacted.make(token),
|
|
473
|
+
expiresAtMillis: exp * 1e3
|
|
474
|
+
};
|
|
475
|
+
}).pipe(Effect.catchTag("JwtError", (error) => Effect.fail(GitHubAppError.of("jwt", error.detail, error))));
|
|
269
476
|
/** A client speaking as the app itself. */
|
|
270
|
-
const asApp = (credentials, options) => Effect.flatMap(mintJwt(credentials), (
|
|
477
|
+
const asApp = (credentials, options) => Effect.flatMap(mintJwt(credentials), ({ token }) => makeClientShape({
|
|
271
478
|
...options,
|
|
272
|
-
token
|
|
479
|
+
token
|
|
273
480
|
}));
|
|
274
481
|
/** A client speaking as a holder of `token`, or as nobody when there is none. */
|
|
275
482
|
const asBearer = (token, options) => makeClientShape({
|
|
@@ -280,10 +487,17 @@ const appFailure = (kind) => (error) => Effect.fail(GitHubAppError.of(kind, erro
|
|
|
280
487
|
function makeApp(options) {
|
|
281
488
|
return Effect.sync(() => {
|
|
282
489
|
const installations = Effect.fn("GitHubApp.installations")(function* (credentials) {
|
|
283
|
-
return (yield* (yield* asApp(credentials, options)).paginate("GET /app/installations", {}).pipe(Effect.catch(appFailure("installation")))).map((entry) =>
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
490
|
+
return (yield* (yield* asApp(credentials, options)).paginate("GET /app/installations", {}).pipe(Effect.catch(appFailure("installation")))).map((entry) => {
|
|
491
|
+
const account = entry.account ?? void 0;
|
|
492
|
+
return Installation.make({
|
|
493
|
+
id: numericId(entry.id),
|
|
494
|
+
...account !== void 0 && typeof account.login === "string" ? { account: account.login } : {},
|
|
495
|
+
...readField("accountType", Schema.String, account?.type),
|
|
496
|
+
...readField("accountId", Schema.Int, typeof account?.id === "bigint" ? numericId(account.id) : account?.id),
|
|
497
|
+
...readField("suspendedAt", SuspendedAt, entry.suspended_at),
|
|
498
|
+
...readField("updatedAt", Schema.DateTimeUtcFromString, entry.updated_at)
|
|
499
|
+
});
|
|
500
|
+
});
|
|
287
501
|
});
|
|
288
502
|
const resolveInstallationId = (request) => request.installationId !== void 0 ? Effect.succeed(request.installationId) : Effect.gen(function* () {
|
|
289
503
|
const all = yield* installations(request);
|
|
@@ -339,48 +553,52 @@ const normalizePermissions = (raw) => {
|
|
|
339
553
|
return out;
|
|
340
554
|
};
|
|
341
555
|
/**
|
|
342
|
-
* A client shape that re-mints its
|
|
556
|
+
* A client shape that re-mints its credential before it expires.
|
|
343
557
|
*
|
|
344
558
|
* @remarks
|
|
345
559
|
* The rotation is invisible to a caller: each member resolves the current
|
|
346
560
|
* client first, and "current" means "minted, and not within a minute of
|
|
347
|
-
* expiry"
|
|
348
|
-
*
|
|
561
|
+
* expiry" (the same skew as {@link InstallationToken.isExpired}). Rotating
|
|
562
|
+
* releases the credential it replaces, so at most one live credential exists
|
|
563
|
+
* at a time and the scope's release releases the last of them. An
|
|
564
|
+
* installation token is released by revoking it; an App JWT has nothing to
|
|
565
|
+
* release.
|
|
349
566
|
*/
|
|
350
|
-
const makeRotatingClient = (
|
|
567
|
+
const makeRotatingClient = (mint, release, options, operation) => Effect.gen(function* () {
|
|
351
568
|
const held = yield* Ref.make(Option.none());
|
|
352
|
-
const revokeHeld = Effect.flatMap(Ref.get(held), (current) => Option.isSome(current) ? Effect.ignore(
|
|
569
|
+
const revokeHeld = Effect.flatMap(Ref.get(held), (current) => Option.isSome(current) ? Effect.ignore(release(current.value.credential.token)) : Effect.void);
|
|
353
570
|
const rotate = Effect.gen(function* () {
|
|
354
571
|
yield* revokeHeld;
|
|
355
|
-
const minted = yield*
|
|
572
|
+
const minted = yield* mint;
|
|
356
573
|
const client = yield* makeClientShape({
|
|
357
574
|
...options,
|
|
358
575
|
token: minted.token
|
|
359
576
|
});
|
|
360
577
|
yield* Ref.set(held, Option.some({
|
|
361
|
-
|
|
578
|
+
credential: minted,
|
|
362
579
|
client
|
|
363
580
|
}));
|
|
364
581
|
return client;
|
|
365
582
|
});
|
|
366
583
|
yield* rotate;
|
|
367
584
|
yield* Effect.addFinalizer(() => revokeHeld);
|
|
368
|
-
|
|
369
|
-
const fresh = Effect.gen(function* () {
|
|
585
|
+
const live = Effect.gen(function* () {
|
|
370
586
|
const now = yield* Clock.currentTimeMillis;
|
|
371
587
|
const state = yield* Ref.get(held);
|
|
372
|
-
|
|
373
|
-
return yield* rotate;
|
|
588
|
+
return Option.isSome(state) && !isSpent(state.value.credential.expiresAtMillis, now) ? Option.some(state.value.client) : Option.none();
|
|
374
589
|
});
|
|
590
|
+
const lock = yield* Semaphore.make(1);
|
|
591
|
+
/** The live client, re-minting first if the held credential is spent. */
|
|
592
|
+
const fresh = Effect.flatMap(live, (current) => Option.isSome(current) ? Effect.succeed(current.value) : lock.withPermit(Effect.flatMap(live, (rechecked) => Option.isSome(rechecked) ? Effect.succeed(rechecked.value) : rotate)));
|
|
375
593
|
const current = fresh.pipe(Effect.catchTag("GitHubAppError", (error) => Effect.fail(new GitHubError({
|
|
376
594
|
kind: "unauthorized",
|
|
377
|
-
operation
|
|
595
|
+
operation,
|
|
378
596
|
reason: error.reason,
|
|
379
597
|
cause: error
|
|
380
598
|
}))));
|
|
381
599
|
const currentForGraphQL = fresh.pipe(Effect.catchTag("GitHubAppError", (error) => Effect.fail(new GitHubGraphQLError({
|
|
382
600
|
kind: "unauthorized",
|
|
383
|
-
operation
|
|
601
|
+
operation,
|
|
384
602
|
reason: error.reason,
|
|
385
603
|
errors: [],
|
|
386
604
|
cause: error
|
package/GitHubClient.js
CHANGED
|
@@ -3,11 +3,49 @@ import { GitHubGraphQLError } from "./GraphQL.js";
|
|
|
3
3
|
import { RetryPolicy } from "./Resilience.js";
|
|
4
4
|
import { makeTransport } from "./internal/octokit.js";
|
|
5
5
|
import { fromArray, paginate } from "./internal/paginate.js";
|
|
6
|
-
import { Config, Context, Effect, Layer, Option, Schema, Stream } from "effect";
|
|
6
|
+
import { Clock, Config, Context, Effect, Layer, Option, Schema, Stream } from "effect";
|
|
7
7
|
|
|
8
8
|
//#region src/GitHubClient.ts
|
|
9
9
|
/** GitHub's own maximum page size, and the default this package requests. */
|
|
10
10
|
const DEFAULT_PER_PAGE = 100;
|
|
11
|
+
/**
|
|
12
|
+
* A raw HTTP failure recorded for {@link GitHubClient.layerFixture}, made by
|
|
13
|
+
* {@link (GitHubFixtures:variable).failure}.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* Branded, so a fixture's data payload that happens to have a `status` and a
|
|
17
|
+
* `body` is never mistaken for a failure: only a value built by
|
|
18
|
+
* `GitHubFixtures.failure` fails the call. The brand is a registered symbol
|
|
19
|
+
* rather than the class identity, so a failure built by another resolved copy
|
|
20
|
+
* of this package is still recognized.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
var RawFailure = class {
|
|
25
|
+
/** The HTTP status. */
|
|
26
|
+
status;
|
|
27
|
+
/** The response headers, in any case. */
|
|
28
|
+
headers;
|
|
29
|
+
/** The JSON body GitHub would send. */
|
|
30
|
+
body;
|
|
31
|
+
/** @internal */
|
|
32
|
+
constructor(response) {
|
|
33
|
+
this.status = response.status;
|
|
34
|
+
this.headers = response.headers;
|
|
35
|
+
this.body = response.body;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** The brand every {@link RawFailure} carries; registered, so it is shared across package copies. */
|
|
39
|
+
const RawFailureBrand = Symbol.for("@effected/github/RawFailure");
|
|
40
|
+
Object.defineProperty(RawFailure.prototype, RawFailureBrand, { value: true });
|
|
41
|
+
/** Whether `value` is a {@link RawFailure}, from this copy of the package or another. */
|
|
42
|
+
const isRawFailure = (value) => typeof value === "object" && value !== null && value[RawFailureBrand] === true;
|
|
43
|
+
/**
|
|
44
|
+
* Builders for {@link (GitHubFixtures:interface)} entries.
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
const GitHubFixtures = { failure: (response) => new RawFailure(response) };
|
|
11
49
|
const resolvePolicy = (retry) => retry === void 0 ? RetryPolicy.default : retry === "off" ? RetryPolicy.none : retry;
|
|
12
50
|
const perPageOf = (options) => options?.perPage ?? DEFAULT_PER_PAGE;
|
|
13
51
|
const unstubbed = (member) => {
|
|
@@ -108,7 +146,14 @@ var GitHubClient = class GitHubClient extends Context.Service()("@effected/githu
|
|
|
108
146
|
* `perPage` and `maxPages` cannot behave differently here than in production.
|
|
109
147
|
*
|
|
110
148
|
* `fixtures.requested` is appended to as the test runs, so a suite can assert
|
|
111
|
-
* which routes were walked and at what page size.
|
|
149
|
+
* which routes were walked, with what params and at what page size.
|
|
150
|
+
*
|
|
151
|
+
* **It records every call it serves, including one that fails.** A call
|
|
152
|
+
* answered by a recorded `GitHubError` or a
|
|
153
|
+
* {@link (GitHubFixtures:variable).failure} is recorded before it fails, so
|
|
154
|
+
* one fixture both records the calls and classifies a raw failure the way
|
|
155
|
+
* the live client would; there is no need to classify by hand inside a
|
|
156
|
+
* {@link GitHubClient.layerTest} handler.
|
|
112
157
|
*/
|
|
113
158
|
static layerFixture = (fixtures) => Layer.succeed(GitHubClient, makeFixture(fixtures));
|
|
114
159
|
};
|
|
@@ -175,6 +220,7 @@ const makeFixture = (fixtures) => {
|
|
|
175
220
|
});
|
|
176
221
|
const recorded = fixtures.paginate?.[route];
|
|
177
222
|
if (recorded instanceof GitHubError) return Stream.fail(recorded);
|
|
223
|
+
if (isRawFailure(recorded)) return Stream.fromEffect(failWith(route, recorded));
|
|
178
224
|
const items = recorded;
|
|
179
225
|
if (items === void 0) switch (fixtures.unstubbed ?? "die") {
|
|
180
226
|
case "fail": return Stream.fail(GitHubError.notFound("GitHubClient.paginate", `fixture for ${route}`));
|
|
@@ -183,6 +229,7 @@ const makeFixture = (fixtures) => {
|
|
|
183
229
|
}
|
|
184
230
|
return paginate(() => fromArray(items, perPage), options?.maxPages);
|
|
185
231
|
};
|
|
232
|
+
const failWith = (route, failure) => Effect.flatMap(Clock.currentTimeMillis, (now) => Effect.fail(GitHubError.fromResponse(route, failure, now)));
|
|
186
233
|
const missing = (method, route) => {
|
|
187
234
|
switch (fixtures.unstubbed ?? "die") {
|
|
188
235
|
case "fail": return Effect.fail(GitHubError.notFound(method, `fixture for ${route}`));
|
|
@@ -199,6 +246,7 @@ const makeFixture = (fixtures) => {
|
|
|
199
246
|
});
|
|
200
247
|
const data = fixtures.request?.[route];
|
|
201
248
|
if (data === void 0) return missing("GitHubClient.request", route);
|
|
249
|
+
if (isRawFailure(data)) return failWith(route, data);
|
|
202
250
|
return data instanceof GitHubError ? Effect.fail(data) : Effect.succeed(data);
|
|
203
251
|
},
|
|
204
252
|
requestDecoded: (route, params, schema) => {
|
|
@@ -210,6 +258,7 @@ const makeFixture = (fixtures) => {
|
|
|
210
258
|
const data = fixtures.request?.[route];
|
|
211
259
|
if (data === void 0) return missing("GitHubClient.requestDecoded", route);
|
|
212
260
|
if (data instanceof GitHubError) return Effect.fail(data);
|
|
261
|
+
if (isRawFailure(data)) return failWith(route, data);
|
|
213
262
|
return Schema.decodeUnknownEffect(schema)(data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubError.decode(route, "fixture did not match its schema", error))));
|
|
214
263
|
},
|
|
215
264
|
paginate: (route, params, options) => Stream.runCollect(paginateStream(route, params, options)),
|
|
@@ -228,4 +277,4 @@ const makeFixture = (fixtures) => {
|
|
|
228
277
|
};
|
|
229
278
|
|
|
230
279
|
//#endregion
|
|
231
|
-
export { GitHubClient, makeClientShape };
|
|
280
|
+
export { GitHubClient, GitHubFixtures, RawFailure, makeClientShape };
|
package/GitHubContent.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { GitHubError } from "./GitHubError.js";
|
|
2
2
|
import { GitHubClient } from "./GitHubClient.js";
|
|
3
3
|
import { Repo } from "./Repo.js";
|
|
4
|
-
import { Context, Effect, Layer, Option } from "effect";
|
|
4
|
+
import { Context, Effect, Layer, Option, Result } from "effect";
|
|
5
|
+
import * as Base64 from "effect/encoding/Base64";
|
|
5
6
|
|
|
6
7
|
//#region src/GitHubContent.ts
|
|
7
8
|
/**
|
|
9
|
+
* Non-fatal, matching `Buffer`'s lenient decode of a non-UTF-8 file; `ignoreBOM`
|
|
10
|
+
* keeps a leading BOM, since it is the file's content rather than framing.
|
|
11
|
+
*/
|
|
12
|
+
const utf8 = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
13
|
+
/**
|
|
8
14
|
* Read a text file out of a repository at a ref, with absence as an `Option`
|
|
9
15
|
* when you want it.
|
|
10
16
|
*
|
|
@@ -58,7 +64,9 @@ const make = (client) => {
|
|
|
58
64
|
if (Array.isArray(content)) return yield* Effect.fail(GitHubError.rejected("GitHubContent.getFile", 422, `${path} is a directory`));
|
|
59
65
|
if (content.type !== "file") return yield* Effect.fail(GitHubError.rejected("GitHubContent.getFile", 422, `${path} is a ${content.type}, not a file`));
|
|
60
66
|
if (content.encoding !== "base64") return yield* Effect.fail(GitHubError.rejected("GitHubContent.getFile", 422, `${path} came back with encoding ${JSON.stringify(content.encoding)} — it is probably too large for the contents API`));
|
|
61
|
-
|
|
67
|
+
const bytes = Base64.decode(content.content.replace(/\s/g, ""));
|
|
68
|
+
if (Result.isFailure(bytes)) return yield* Effect.fail(GitHubError.decode("GitHubContent.getFile", `${path} did not come back as valid base64`, bytes.failure));
|
|
69
|
+
return utf8.decode(bytes.success);
|
|
62
70
|
});
|
|
63
71
|
return {
|
|
64
72
|
getFile,
|
package/GitHubError.js
CHANGED
|
@@ -175,18 +175,28 @@ var GitHubError = class GitHubError extends Schema.TaggedError()("GitHubError",
|
|
|
175
175
|
* under test.
|
|
176
176
|
*/
|
|
177
177
|
static fromOctokit(operation, error, nowMillis) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
178
|
+
return fromFacts(operation, readThrowable(error), nowMillis, error);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Classify a raw HTTP response: a status, its headers and its JSON body.
|
|
182
|
+
*
|
|
183
|
+
* @remarks
|
|
184
|
+
* For a response that never went through octokit, such as one a test
|
|
185
|
+
* records with `GitHubFixtures.failure` or one a consumer fetched itself. It
|
|
186
|
+
* reads the same facts {@link GitHubError.fromOctokit} reads (`reason` from
|
|
187
|
+
* `body.message`, else `HTTP <status>`, sanitized the same way; `validation`
|
|
188
|
+
* from `body.errors`) and runs the same classifier, so a raw response and
|
|
189
|
+
* the octokit error for it classify identically.
|
|
190
|
+
*
|
|
191
|
+
* Header names are matched in any case. `nowMillis` is required, as in
|
|
192
|
+
* {@link GitHubError.fromOctokit}: it turns an absolute rate-limit reset
|
|
193
|
+
* (an epoch second) into a delay, so it must be the real current time,
|
|
194
|
+
* usually `Clock.currentTimeMillis`. A zero would make the delay decades.
|
|
195
|
+
*/
|
|
196
|
+
static fromResponse(operation, response, nowMillis) {
|
|
197
|
+
const body = asRecord(response.body);
|
|
198
|
+
const message = typeof body?.message === "string" ? body.message : `HTTP ${response.status}`;
|
|
199
|
+
return fromFacts(operation, factsFrom(response.status, lowercaseKeys(response.headers), message, body), nowMillis, void 0);
|
|
190
200
|
}
|
|
191
201
|
/**
|
|
192
202
|
* A predicate over one or more kinds, for `Effect.catchIf`.
|
|
@@ -251,15 +261,35 @@ const readThrowable = (error) => {
|
|
|
251
261
|
};
|
|
252
262
|
const record = error;
|
|
253
263
|
const response = asRecord(record.response);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
return factsFrom(typeof record.status === "number" ? record.status : void 0, asRecord(response?.headers), typeof record.message === "string" ? record.message : String(error), asRecord(response?.data));
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* The classification facts from a response's parts: the half shared by
|
|
268
|
+
* {@link GitHubError.fromOctokit} and {@link GitHubError.fromResponse}.
|
|
269
|
+
* `headers` must already be lowercase-keyed.
|
|
270
|
+
*/
|
|
271
|
+
const factsFrom = (status, headers, message, data) => ({
|
|
272
|
+
status,
|
|
273
|
+
headers,
|
|
274
|
+
reason: sanitizeReason(message),
|
|
275
|
+
validation: readValidation(data)
|
|
276
|
+
});
|
|
277
|
+
/** Classify `facts` and build the error: the one place a `GitHubError` is classified. */
|
|
278
|
+
const fromFacts = (operation, facts, nowMillis, cause) => {
|
|
279
|
+
const retryAfterMillis = retryAfterMillisFrom(facts.headers, nowMillis);
|
|
280
|
+
const kind = classify(facts, retryAfterMillis);
|
|
281
|
+
return new GitHubError({
|
|
282
|
+
kind,
|
|
283
|
+
operation,
|
|
284
|
+
reason: facts.reason,
|
|
285
|
+
...facts.status !== void 0 ? { status: facts.status } : {},
|
|
286
|
+
...retryAfterMillis !== void 0 ? { retryAfterMillis } : {},
|
|
287
|
+
...facts.validation.length > 0 ? { validation: facts.validation } : {},
|
|
288
|
+
...cause !== void 0 ? { cause } : {}
|
|
289
|
+
});
|
|
262
290
|
};
|
|
291
|
+
/** Header names as octokit delivers them: lowercase. A caller may write `Retry-After`. */
|
|
292
|
+
const lowercaseKeys = (headers) => headers === void 0 ? void 0 : Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
|
|
263
293
|
const asRecord = (value) => typeof value === "object" && value !== null ? value : void 0;
|
|
264
294
|
/**
|
|
265
295
|
* GitHub answers some requests with an HTML error page whose body becomes a
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Clock, Context, Duration, Effect, Layer, Option, Ref } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/InstallationTokenStore.ts
|
|
4
|
+
/**
|
|
5
|
+
* Where `GitHubApp.cachedToken` keeps installation tokens between request
|
|
6
|
+
* scopes.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Entries are keyed by installation id, never by token text. The value is an
|
|
10
|
+
* `InstallationToken` encoded as JSON, and it **contains the raw token**, so
|
|
11
|
+
* encrypting it at rest is the store's job, not the caller's.
|
|
12
|
+
*
|
|
13
|
+
* {@link InstallationTokenStore.layerMemory} keeps tokens in process. A
|
|
14
|
+
* Cloudflare Worker, whose isolates do not share memory, backs the store with
|
|
15
|
+
* KV, a Durable Object or D1 instead. A store's own failures should be
|
|
16
|
+
* swallowed by the store (a miss on `get`, a no-op on `set`): a cache must
|
|
17
|
+
* never fail the call it serves. `cachedToken` also guards against a store
|
|
18
|
+
* that fails or dies anyway; only interruption propagates.
|
|
19
|
+
*
|
|
20
|
+
* This module imports nothing but `effect`, so a store implementation never
|
|
21
|
+
* links the JWT signer.
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
var InstallationTokenStore = class InstallationTokenStore extends Context.Service()("@effected/github/InstallationTokenStore") {
|
|
26
|
+
/** An in-process store over a `Ref`, expiring entries against `Clock`. */
|
|
27
|
+
static layerMemory = Layer.effect(this, Effect.map(Ref.make(/* @__PURE__ */ new Map()), (entries) => ({
|
|
28
|
+
get: (installationId) => Effect.gen(function* () {
|
|
29
|
+
const now = yield* Clock.currentTimeMillis;
|
|
30
|
+
const entry = (yield* Ref.get(entries)).get(installationId);
|
|
31
|
+
return entry !== void 0 && now < entry.expiresAtMillis ? Option.some(entry.encoded) : Option.none();
|
|
32
|
+
}),
|
|
33
|
+
set: (installationId, encoded, ttl) => Effect.gen(function* () {
|
|
34
|
+
const expiresAtMillis = (yield* Clock.currentTimeMillis) + Duration.toMillis(ttl);
|
|
35
|
+
yield* Ref.update(entries, (map) => new Map(map).set(installationId, {
|
|
36
|
+
encoded,
|
|
37
|
+
expiresAtMillis
|
|
38
|
+
}));
|
|
39
|
+
})
|
|
40
|
+
})));
|
|
41
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
42
|
+
static makeTest = (overrides = {}) => ({
|
|
43
|
+
get: overrides.get ?? (() => Effect.die(/* @__PURE__ */ new Error("InstallationTokenStore.makeTest: get() was called but not stubbed"))),
|
|
44
|
+
set: overrides.set ?? (() => Effect.die(/* @__PURE__ */ new Error("InstallationTokenStore.makeTest: set() was called but not stubbed")))
|
|
45
|
+
});
|
|
46
|
+
/** {@link InstallationTokenStore.makeTest} behind a `Layer`. */
|
|
47
|
+
static layerTest = (overrides = {}) => Layer.succeed(InstallationTokenStore, InstallationTokenStore.makeTest(overrides));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
//#endregion
|
|
51
|
+
export { InstallationTokenStore };
|