@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/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 { Clock, Context, DateTime, Duration, Effect, Layer, Option, Redacted, Ref, Schema, Stream } from "effect";
6
- import githubAppJwt from "universal-github-app-jwt";
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) - Duration.toMillis(skew) <= nowMillis;
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 `universal-github-app-jwt` — zero dependencies, and
191
- * `@octokit/auth-app`'s own JWT dependency.
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, request, options))).pipe(Layer.provide(GitHubApp.layerWith(options)));
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. The only cryptography in this package, and it is a leaf call. */
262
- const mintJwt = (credentials) => Effect.tryPromise({
263
- try: () => githubAppJwt({
264
- id: credentials.appId,
265
- privateKey: Redacted.value(credentials.privateKey)
266
- }),
267
- catch: (error) => GitHubAppError.of("jwt", error instanceof Error ? error.message : "could not sign the app JWT", error)
268
- }).pipe(Effect.map((result) => Redacted.make(result.token)));
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), (jwt) => makeClientShape({
477
+ const asApp = (credentials, options) => Effect.flatMap(mintJwt(credentials), ({ token }) => makeClientShape({
271
478
  ...options,
272
- token: jwt
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) => Installation.make({
284
- id: numericId(entry.id),
285
- ...entry.account !== null && entry.account !== void 0 && "login" in entry.account ? { account: entry.account.login } : {}
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 installation token before it expires.
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". Rotating revokes the token it replaces, so at most one live token
348
- * exists at a time and the scope's release revokes the last of them.
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 = (app, request, options) => Effect.gen(function* () {
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(app.revoke(current.value.token.token)) : Effect.void);
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* app.token(request);
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
- token: minted,
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
- /** The live client, re-minting first if the held token is spent. */
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
- if (Option.isSome(state) && !state.value.token.isExpired(now)) return state.value.client;
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: "GitHubApp.clientLayer",
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: "GitHubApp.clientLayer",
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
- return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8");
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
- const facts = readThrowable(error);
179
- const retryAfterMillis = retryAfterMillisFrom(facts.headers, nowMillis);
180
- const kind = classify(facts, retryAfterMillis);
181
- return new GitHubError({
182
- kind,
183
- operation,
184
- reason: facts.reason,
185
- ...facts.status !== void 0 ? { status: facts.status } : {},
186
- ...retryAfterMillis !== void 0 ? { retryAfterMillis } : {},
187
- ...facts.validation.length > 0 ? { validation: facts.validation } : {},
188
- cause: error
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
- const headers = asRecord(response?.headers);
255
- const data = asRecord(response?.data);
256
- return {
257
- status: typeof record.status === "number" ? record.status : void 0,
258
- headers,
259
- reason: sanitizeReason(typeof record.message === "string" ? record.message : String(error)),
260
- validation: readValidation(data)
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 };