@effected/github 0.25.1 → 0.27.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 CHANGED
@@ -143,7 +143,8 @@ const testClaims = (overrides = {}) => ({
143
143
  * @remarks
144
144
  * {@link ActionsOidc.verify} is `@effected/oidc`'s `Oidc.verify` pinned to the
145
145
  * Actions issuer: it checks the signature against GitHub's published JWKS
146
- * (through `@effected/jwt`'s `JwksResolver`, which caches it), then `iss`,
146
+ * (through `@effected/jwt`'s `JwksResolver`, which caches it; the signature
147
+ * itself runs on core `effect/jose` and needs core's `Signature` service), then `iss`,
147
148
  * `aud`, `exp`, `nbf` and `iat`, then decodes the payload into
148
149
  * {@link ActionsOidcClaims}. Every failure is a `JwtError`; route on its
149
150
  * `reason` (`wrongAudience`, `expired`, `claims`, ...), never on its message.
@@ -168,7 +169,8 @@ const testClaims = (overrides = {}) => ({
168
169
  * ```ts
169
170
  * import { JwksResolver } from "@effected/jwt";
170
171
  * import { ActionsOidc } from "@effected/github";
171
- * import { Data, Effect } from "effect";
172
+ * import { Data, Effect, Layer } from "effect";
173
+ * import { SecureRandom, Signature } from "effect/crypto";
172
174
  *
173
175
  * class Forbidden extends Data.TaggedError("Forbidden")<{ readonly repositoryId: number }> {}
174
176
  *
@@ -186,7 +188,8 @@ const testClaims = (overrides = {}) => ({
186
188
  * });
187
189
  *
188
190
  * // JwksResolver.layer needs an HttpClient and a JwksStore, provided once at the edge.
189
- * export const program = authorize("eyJ...").pipe(Effect.provide(JwksResolver.layer));
191
+ * const CryptoLive = Signature.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto));
192
+ * export const program = authorize("eyJ...").pipe(Effect.provide(Layer.merge(JwksResolver.layer, CryptoLive)));
190
193
  * ```
191
194
  *
192
195
  * @public
@@ -195,7 +198,7 @@ var ActionsOidc = class {
195
198
  constructor() {}
196
199
  /** The Actions OIDC issuer, `https://token.actions.githubusercontent.com`. */
197
200
  static issuer = ISSUER;
198
- /** Verify a runner-issued OIDC token: signature against GitHub's JWKS, iss, aud, exp/nbf, then typed claims. */
201
+ /** Verify a runner-issued OIDC token: signature against GitHub's JWKS, iss, aud, exp/nbf, then typed claims; requires `JwksResolver` and core `Signature`. */
199
202
  static verify = verify;
200
203
  /**
201
204
  * A valid claims payload for tests, overridable field by field.
@@ -1,3 +1,4 @@
1
+ import { ENVIRONMENT_PUT_ROUTE_PARAMS, reservedBodyKey } from "./internal/requestOptions.js";
1
2
  import { unstubbed } from "./internal/unstubbed.js";
2
3
  import { GitHubClient } from "./GitHubClient.js";
3
4
  import { Repo } from "./Repo.js";
@@ -54,11 +55,15 @@ const make = (client) => {
54
55
  repo,
55
56
  environment: name
56
57
  });
58
+ for (const key of Object.keys(config)) {
59
+ const reason = reservedBodyKey(key, ENVIRONMENT_PUT_ROUTE_PARAMS, "an environment setting");
60
+ if (reason !== void 0) return yield* Effect.die(/* @__PURE__ */ new RangeError(`DeploymentEnvironment.upsert: "${key}" ${reason}`));
61
+ }
57
62
  yield* client.request("PUT /repos/{owner}/{repo}/environments/{environment_name}", {
58
- ...config,
59
63
  owner,
60
64
  repo,
61
- environment_name: name
65
+ environment_name: name,
66
+ data: { ...config }
62
67
  });
63
68
  }),
64
69
  list: Effect.fn("DeploymentEnvironment.list")(function* () {
package/GitHubApp.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { GitHubError } from "./GitHubError.js";
2
2
  import { GitHubGraphQLError } from "./GraphQL.js";
3
+ import { uploadChannelOf, withUploadChannel } from "./internal/uploads.js";
3
4
  import { unstubbed } from "./internal/unstubbed.js";
4
5
  import { GitHubClient, makeClientShape, resolveApiVersion } from "./GitHubClient.js";
5
6
  import { numericId } from "./internal/ids.js";
6
7
  import { InstallationTokenStore } from "./InstallationTokenStore.js";
7
8
  import { Cause, Clock, Context, DateTime, Deferred, Duration, Effect, Layer, Option, Redacted, Ref, Schema, Semaphore, Stream } from "effect";
8
9
  import { Jwt, JwtKey } from "@effected/jwt";
10
+ import { Signature } from "effect/crypto";
9
11
 
10
12
  // src/GitHubApp.ts
11
13
  /**
@@ -354,7 +356,7 @@ var GitHubApp = class GitHubApp extends Context.Service()("@effected/github/GitH
354
356
  * Effect.runPromise(Effect.provide(sweep, layer));
355
357
  * ```
356
358
  */
357
- static appClientLayer = (credentials, options = {}) => Layer.effect(GitHubClient, makeRotatingClient(mintJwt(credentials), () => Effect.void, options, "GitHubApp.appClientLayer"));
359
+ static appClientLayer = (credentials, options = {}) => Layer.effect(GitHubClient, Effect.flatMap(Effect.service(Signature.Signature), (signature) => makeRotatingClient(Effect.provideService(mintJwt(credentials), Signature.Signature, signature), () => Effect.void, options, "GitHubApp.appClientLayer")));
358
360
  /**
359
361
  * An installation token, reused from an {@link InstallationTokenStore}
360
362
  * while it has `margin` (five minutes by default) left to live.
@@ -533,7 +535,7 @@ const mintJwt = (credentials) => Effect.gen(function* () {
533
535
  };
534
536
  }).pipe(Effect.catchTag("JwtError", (error) => Effect.fail(GitHubAppError.of("jwt", error.detail, error))));
535
537
  /** A client speaking as the app itself. */
536
- const asApp = (credentials, options) => Effect.flatMap(mintJwt(credentials), ({ token }) => makeClientShape({
538
+ const appClientOf = (credentials, options) => Effect.flatMap(mintJwt(credentials), ({ token }) => makeClientShape({
537
539
  ...options,
538
540
  token
539
541
  }));
@@ -544,75 +546,77 @@ const asBearer = (token, options) => makeClientShape({
544
546
  });
545
547
  const appFailure = (kind) => (error) => Effect.fail(GitHubAppError.of(kind, error.reason, error));
546
548
  function makeApp(options) {
547
- return Effect.map(resolveApiVersion(options.apiVersion), () => {
548
- const installations = Effect.fn("GitHubApp.installations")(function* (credentials) {
549
- return (yield* (yield* asApp(credentials, options)).paginate("GET /app/installations", {}).pipe(Effect.catch(appFailure("installation")))).map((entry) => {
550
- const account = entry.account ?? void 0;
551
- return Installation.make({
552
- id: numericId(entry.id),
553
- ...account !== void 0 && typeof account.login === "string" ? { account: account.login } : {},
554
- ...readField("accountType", Schema.String, account?.type),
555
- ...readField("accountId", Schema.Int, typeof account?.id === "bigint" ? numericId(account.id) : account?.id),
556
- ...readField("suspendedAt", SuspendedAt, entry.suspended_at),
557
- ...readField("updatedAt", Schema.DateTimeUtcFromString, entry.updated_at)
558
- });
549
+ return Effect.flatMap(Effect.service(Signature.Signature), (signature) => Effect.map(resolveApiVersion(options.apiVersion), () => makeAppWith(options, signature)));
550
+ }
551
+ function makeAppWith(options, signature) {
552
+ const asApp = (credentials, clientOptions) => Effect.provideService(appClientOf(credentials, clientOptions), Signature.Signature, signature);
553
+ const installations = Effect.fn("GitHubApp.installations")(function* (credentials) {
554
+ return (yield* (yield* asApp(credentials, options)).paginate("GET /app/installations", {}).pipe(Effect.catch(appFailure("installation")))).map((entry) => {
555
+ const account = entry.account ?? void 0;
556
+ return Installation.make({
557
+ id: numericId(entry.id),
558
+ ...account !== void 0 && typeof account.login === "string" ? { account: account.login } : {},
559
+ ...readField("accountType", Schema.String, account?.type),
560
+ ...readField("accountId", Schema.Int, typeof account?.id === "bigint" ? numericId(account.id) : account?.id),
561
+ ...readField("suspendedAt", SuspendedAt, entry.suspended_at),
562
+ ...readField("updatedAt", Schema.DateTimeUtcFromString, entry.updated_at)
559
563
  });
560
564
  });
561
- const resolveInstallationId = (request) => request.installationId !== void 0 ? Effect.succeed(request.installationId) : Effect.gen(function* () {
562
- const all = yield* installations(request);
563
- if (request.owner !== void 0) {
564
- const wanted = request.owner.toLowerCase();
565
- const match = all.find((entry) => entry.account?.toLowerCase() === wanted);
566
- if (match !== void 0) return match.id;
567
- return yield* Effect.fail(GitHubAppError.of("installation", `the app is not installed on ${request.owner} (installed on: ${all.map((entry) => entry.account ?? entry.id).join(", ") || "nothing"})`));
568
- }
569
- const only = all[0];
570
- if (all.length === 1 && only !== void 0) return only.id;
571
- return yield* Effect.fail(GitHubAppError.of("installation", all.length === 0 ? "the app has no installations" : `the app has ${all.length} installations; pass installationId or owner`));
572
- });
573
- const token = Effect.fn("GitHubApp.token")(function* (request) {
574
- const installationId = yield* resolveInstallationId(request);
575
- const minted = yield* (yield* asApp(request, options)).request("POST /app/installations/{installation_id}/access_tokens", { installation_id: installationId }).pipe(Effect.catch(appFailure("token")));
576
- return yield* Schema.decodeUnknownEffect(InstallationToken)({
577
- token: minted.token,
578
- expiresAt: minted.expires_at,
579
- installationId,
580
- permissions: normalizePermissions(minted.permissions)
581
- }).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("token", "GitHub returned an unexpected token payload", error))));
582
- });
583
- const revoke = Effect.fn("GitHubApp.revoke")(function* (value) {
584
- yield* (yield* asBearer(value, options)).request("DELETE /installation/token", {}).pipe(Effect.catch(appFailure("revoke")));
585
- });
586
- const scopedToken = (request) => Effect.acquireRelease(token(request), (minted) => Effect.ignore(revoke(minted.token)));
587
- return {
588
- token,
589
- scopedToken,
590
- revoke,
591
- identity: Effect.fn("GitHubApp.identity")(function* (request) {
592
- const app = yield* (yield* asApp(request, options)).request("GET /app", {}).pipe(Effect.catch(appFailure("identity")));
593
- if (app === null) return yield* Effect.fail(GitHubAppError.of("identity", "GET /app returned no app"));
594
- const slug = app.slug ?? "";
595
- const name = app.name;
596
- const user = yield* (yield* asBearer(request.installationToken, options)).request("GET /users/{username}", { username: `${slug}[bot]` }).pipe(Effect.option);
597
- return AppIdentity.make({
598
- slug,
599
- name,
600
- ...Option.isSome(user) ? { userId: numericId(user.value.id) } : {}
601
- });
602
- }),
603
- botUser: Effect.fn("GitHubApp.botUser")(function* (request) {
604
- yield* Effect.annotateCurrentSpan({ slug: request.slug });
605
- const user = yield* (yield* asBearer(request.installationToken, options)).request("GET /users/{username}", { username: `${request.slug}[bot]` }).pipe(Effect.catch(appFailure("identity")));
606
- const raw = user;
607
- return yield* Schema.decodeUnknownEffect(BotUser)(typeof raw === "object" && raw !== null ? {
608
- id: numericId(user.id),
609
- login: user.login
610
- } : raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("identity", "GitHub returned an unexpected bot user", error))));
611
- }),
612
- installations,
613
- cachedToken: makeCachedToken(token)
614
- };
615
565
  });
566
+ const resolveInstallationId = (request) => request.installationId !== void 0 ? Effect.succeed(request.installationId) : Effect.gen(function* () {
567
+ const all = yield* installations(request);
568
+ if (request.owner !== void 0) {
569
+ const wanted = request.owner.toLowerCase();
570
+ const match = all.find((entry) => entry.account?.toLowerCase() === wanted);
571
+ if (match !== void 0) return match.id;
572
+ return yield* Effect.fail(GitHubAppError.of("installation", `the app is not installed on ${request.owner} (installed on: ${all.map((entry) => entry.account ?? entry.id).join(", ") || "nothing"})`));
573
+ }
574
+ const only = all[0];
575
+ if (all.length === 1 && only !== void 0) return only.id;
576
+ return yield* Effect.fail(GitHubAppError.of("installation", all.length === 0 ? "the app has no installations" : `the app has ${all.length} installations; pass installationId or owner`));
577
+ });
578
+ const token = Effect.fn("GitHubApp.token")(function* (request) {
579
+ const installationId = yield* resolveInstallationId(request);
580
+ const minted = yield* (yield* asApp(request, options)).request("POST /app/installations/{installation_id}/access_tokens", { installation_id: installationId }).pipe(Effect.catch(appFailure("token")));
581
+ return yield* Schema.decodeUnknownEffect(InstallationToken)({
582
+ token: minted.token,
583
+ expiresAt: minted.expires_at,
584
+ installationId,
585
+ permissions: normalizePermissions(minted.permissions)
586
+ }).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("token", "GitHub returned an unexpected token payload", error))));
587
+ });
588
+ const revoke = Effect.fn("GitHubApp.revoke")(function* (value) {
589
+ yield* (yield* asBearer(value, options)).request("DELETE /installation/token", {}).pipe(Effect.catch(appFailure("revoke")));
590
+ });
591
+ const scopedToken = (request) => Effect.acquireRelease(token(request), (minted) => Effect.ignore(revoke(minted.token)));
592
+ return {
593
+ token,
594
+ scopedToken,
595
+ revoke,
596
+ identity: Effect.fn("GitHubApp.identity")(function* (request) {
597
+ const app = yield* (yield* asApp(request, options)).request("GET /app", {}).pipe(Effect.catch(appFailure("identity")));
598
+ if (app === null) return yield* Effect.fail(GitHubAppError.of("identity", "GET /app returned no app"));
599
+ const slug = app.slug ?? "";
600
+ const name = app.name;
601
+ const user = yield* (yield* asBearer(request.installationToken, options)).request("GET /users/{username}", { username: `${slug}[bot]` }).pipe(Effect.option);
602
+ return AppIdentity.make({
603
+ slug,
604
+ name,
605
+ ...Option.isSome(user) ? { userId: numericId(user.value.id) } : {}
606
+ });
607
+ }),
608
+ botUser: Effect.fn("GitHubApp.botUser")(function* (request) {
609
+ yield* Effect.annotateCurrentSpan({ slug: request.slug });
610
+ const user = yield* (yield* asBearer(request.installationToken, options)).request("GET /users/{username}", { username: `${request.slug}[bot]` }).pipe(Effect.catch(appFailure("identity")));
611
+ const raw = user;
612
+ return yield* Schema.decodeUnknownEffect(BotUser)(typeof raw === "object" && raw !== null ? {
613
+ id: numericId(user.id),
614
+ login: user.login
615
+ } : raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("identity", "GitHub returned an unexpected bot user", error))));
616
+ }),
617
+ installations,
618
+ cachedToken: makeCachedToken(token)
619
+ };
616
620
  }
617
621
  /** GitHub's permission values are strings; anything else is not a permission. */
618
622
  const normalizePermissions = (raw) => {
@@ -673,7 +677,11 @@ const makeRotatingClient = (mint, release, options, operation) => Effect.gen(fun
673
677
  errors: [],
674
678
  cause: error
675
679
  }))));
676
- return {
680
+ const upload = (route, params, schema) => Effect.flatMap(current, (client) => {
681
+ const channel = uploadChannelOf(client);
682
+ return channel === void 0 ? Effect.die(/* @__PURE__ */ new Error(`${operation}: the inner client carries no upload channel`)) : channel.upload(route, params, schema);
683
+ });
684
+ const shape = {
677
685
  request: (route, params) => Effect.flatMap(current, (client) => client.request(route, params)),
678
686
  requestDecoded: (route, params, schema) => Effect.flatMap(current, (client) => client.requestDecoded(route, params, schema)),
679
687
  paginate: (route, params, pageOptions) => Effect.flatMap(current, (client) => client.paginate(route, params, pageOptions)),
@@ -682,6 +690,10 @@ const makeRotatingClient = (mint, release, options, operation) => Effect.gen(fun
682
690
  rateLimit: Effect.flatMap(Ref.get(held), (state) => Option.isSome(state) ? state.value.client.rateLimit : Effect.succeed(Option.none())),
683
691
  apiVersion
684
692
  };
693
+ return withUploadChannel(shape, {
694
+ baseUrl: options.baseUrl,
695
+ upload
696
+ });
685
697
  });
686
698
 
687
699
  export { AppIdentity, BotIdentity, BotUser, GitHubApp, GitHubAppError, Installation, InstallationToken };
package/GitHubClient.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { GitHubError } from "./GitHubError.js";
2
2
  import { GitHubGraphQLError } from "./GraphQL.js";
3
+ import { DOTCOM_API_BASE_URL, originOf, uploadsBaseUrlFor, withUploadChannel } from "./internal/uploads.js";
4
+ import { rawRequestRefusal, refusedRequest } from "./internal/confinement.js";
3
5
  import { RetryPolicy } from "./Resilience.js";
4
- import { DEFAULT_API_VERSION, makeTransport } from "./internal/octokit.js";
6
+ import { DEFAULT_API_VERSION, expandOnDotcom, makeTransport } from "./internal/octokit.js";
5
7
  import { fromArray, paginate } from "./internal/paginate.js";
6
8
  import { unstubbed } from "./internal/unstubbed.js";
7
9
  import { Clock, Config, Context, Effect, Layer, Option, Schema, Stream } from "effect";
@@ -82,6 +84,34 @@ var GitHubFixtures = class {
82
84
  const resolvePolicy = (retry) => retry === void 0 ? RetryPolicy.default : retry === "off" ? RetryPolicy.none : retry;
83
85
  const perPageOf = (options) => options?.perPage ?? DEFAULT_PER_PAGE;
84
86
  const dies = unstubbed("GitHubClient.makeTest", "pass an override, or use GitHubClient.layerFixture for recorded responses.");
87
+ const DOTCOM_API_ORIGIN = originOf(DOTCOM_API_BASE_URL);
88
+ const doubleRefusal = (route, params) => rawRequestRefusal(expandOnDotcom, route, typeof params === "object" && params !== null ? params : {}, DOTCOM_API_ORIGIN);
89
+ /**
90
+ * `shape` with the live client's token-confinement refusals applied in front of
91
+ * its four raw members, so a suite on a double sees the `rejected` failure
92
+ * production would raise. A refused call never reaches the double's handler,
93
+ * stub, fixture or `requested` log, as a refused live call never reaches the
94
+ * wire.
95
+ */
96
+ const confineDouble = (shape) => ({
97
+ ...shape,
98
+ request: (route, params) => Effect.suspend(() => {
99
+ const reason = doubleRefusal(route, params);
100
+ return reason !== void 0 ? Effect.fail(refusedRequest(route, reason)) : shape.request(route, params);
101
+ }),
102
+ requestDecoded: (route, params, schema) => Effect.suspend(() => {
103
+ const reason = doubleRefusal(route, params);
104
+ return reason !== void 0 ? Effect.fail(refusedRequest(route, reason)) : shape.requestDecoded(route, params, schema);
105
+ }),
106
+ paginate: (route, params, options) => Effect.suspend(() => {
107
+ const reason = doubleRefusal(route, params);
108
+ return reason !== void 0 ? Effect.fail(refusedRequest(route, reason)) : shape.paginate(route, params, options);
109
+ }),
110
+ paginateStream: (route, params, options) => Stream.suspend(() => {
111
+ const reason = doubleRefusal(route, params);
112
+ return reason !== void 0 ? Stream.fail(refusedRequest(route, reason)) : shape.paginateStream(route, params, options);
113
+ })
114
+ });
85
115
  /**
86
116
  * The typed GitHub API client.
87
117
  *
@@ -157,10 +187,16 @@ var GitHubClient = class GitHubClient extends Context.Service()("@effected/githu
157
187
  * `rateLimit` answers `Option.none()` and `apiVersion` the live default,
158
188
  * because a real client legitimately answers both without a request.
159
189
  *
190
+ * It refuses what the live client refuses, before any override runs: a
191
+ * `baseUrl`, `url`, `request` or `method` parameter (a body field called
192
+ * `url` goes inside `data`), or a route that resolves off github.com's REST
193
+ * root, fails `kind: "rejected"` with no status. A double has no
194
+ * configured base, so an absolute-URL route must name `api.github.com`.
195
+ *
160
196
  * For recorded responses that page for real, use
161
197
  * {@link GitHubClient.layerFixture}.
162
198
  */
163
- static makeTest = (overrides = {}) => ({
199
+ static makeTest = (overrides = {}) => confineDouble({
164
200
  request: overrides.request ?? (() => dies("request")),
165
201
  requestDecoded: overrides.requestDecoded ?? (() => dies("requestDecoded")),
166
202
  paginate: overrides.paginate ?? (() => dies("paginate")),
@@ -189,8 +225,13 @@ var GitHubClient = class GitHubClient extends Context.Service()("@effected/githu
189
225
  * one fixture both records the calls and classifies a raw failure the way
190
226
  * the live client would; there is no need to classify by hand inside a
191
227
  * {@link GitHubClient.layerTest} handler.
228
+ *
229
+ * It refuses what the live client refuses, exactly as
230
+ * {@link GitHubClient.makeTest} does. A refused call fails
231
+ * `kind: "rejected"` and is **not** recorded in `requested`: it is never
232
+ * served, as a refused live call never reaches the wire.
192
233
  */
193
- static layerFixture = (fixtures) => Layer.succeed(GitHubClient, makeFixture(fixtures));
234
+ static layerFixture = (fixtures) => Layer.succeed(GitHubClient, confineDouble(makeFixture(fixtures)));
194
235
  };
195
236
  /** A calendar api-version: `YYYY-MM-DD`. */
196
237
  const API_VERSION_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -231,35 +272,43 @@ const makeClientShape = (options) => Effect.gen(function* () {
231
272
  yield* Effect.annotateCurrentSpan({ route });
232
273
  return (yield* transport.request(route, route, params)).data;
233
274
  });
275
+ const decodeWith = (route, schema) => (data) => Schema.decodeUnknownEffect(schema)(data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubError.decode(route, "response did not match its schema", error))));
234
276
  const requestDecoded = Effect.fn("GitHubClient.requestDecoded")(function* (route, params, schema) {
235
277
  yield* Effect.annotateCurrentSpan({ route });
236
278
  const response = yield* transport.request(route, route, params);
237
- return yield* Schema.decodeUnknownEffect(schema)(response.data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubError.decode(route, "response did not match its schema", error))));
279
+ return yield* decodeWith(route, schema)(response.data);
238
280
  });
281
+ const uploadsBaseUrl = uploadsBaseUrlFor(options.baseUrl);
282
+ const upload = (route, params, schema) => Effect.flatMap(transport.requestAt(route, route, params, uploadsBaseUrl), (response) => decodeWith(route, schema)(response.data));
239
283
  const paginateStream = (route, params, options) => paginate(() => transport.pageSource(route, route, {
240
284
  per_page: perPageOf(options),
241
285
  ...params
242
286
  }), options?.maxPages);
243
- return {
287
+ const paginateAll = Effect.fn("GitHubClient.paginate")(function* (route, params, options) {
288
+ yield* Effect.annotateCurrentSpan({
289
+ route,
290
+ perPage: perPageOf(options),
291
+ maxPages: options?.maxPages ?? -1
292
+ });
293
+ return yield* Stream.runCollect(paginateStream(route, params, options));
294
+ });
295
+ const graphql = Effect.fn("GitHubClient.graphql")(function* (document, variables) {
296
+ yield* Effect.annotateCurrentSpan({ document: document.name });
297
+ const raw = yield* transport.graphql(document.name, document.document, document.encodeVariables(variables));
298
+ return yield* document.decode(raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubGraphQLError.decode(document.name, "response did not match its schema", error))));
299
+ });
300
+ return withUploadChannel({
244
301
  request,
245
302
  requestDecoded,
246
- paginate: Effect.fn("GitHubClient.paginate")(function* (route, params, options) {
247
- yield* Effect.annotateCurrentSpan({
248
- route,
249
- perPage: perPageOf(options),
250
- maxPages: options?.maxPages ?? -1
251
- });
252
- return yield* Stream.runCollect(paginateStream(route, params, options));
253
- }),
303
+ paginate: paginateAll,
254
304
  paginateStream,
255
- graphql: Effect.fn("GitHubClient.graphql")(function* (document, variables) {
256
- yield* Effect.annotateCurrentSpan({ document: document.name });
257
- const raw = yield* transport.graphql(document.name, document.document, document.encodeVariables(variables));
258
- return yield* document.decode(raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubGraphQLError.decode(document.name, "response did not match its schema", error))));
259
- }),
305
+ graphql,
260
306
  rateLimit: transport.rateLimit,
261
307
  apiVersion
262
- };
308
+ }, {
309
+ baseUrl: options.baseUrl,
310
+ upload
311
+ });
263
312
  });
264
313
  const makeFixture = (fixtures) => {
265
314
  const requested = fixtures.requested;
package/GitHubError.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { headerNumber, retryAfterMillisFrom } from "./internal/headers.js";
2
+ import { scrubThrowable } from "./internal/scrub.js";
2
3
  import { Schema } from "effect";
3
4
 
4
5
  // src/GitHubError.ts
@@ -106,7 +107,7 @@ var GitHubError = class GitHubError extends Schema.TaggedError()("GitHubError",
106
107
  * 422s ("Update is not a fast forward") are prose only.
107
108
  */
108
109
  validation: Schema.optionalKey(Schema.Array(GitHubValidationEntry)),
109
- /** The underlying throwable, when one exists. */
110
+ /** The underlying throwable, when one exists; an octokit throwable is kept as a credential-free copy. */
110
111
  cause: Schema.optionalKey(Schema.Defect())
111
112
  }) {
112
113
  /** `"GitBranch.upsert failed (422): Reference already exists"`. */
@@ -173,9 +174,19 @@ var GitHubError = class GitHubError extends Schema.TaggedError()("GitHubError",
173
174
  * epoch second, and turning it into a delay needs a "now" the caller controls.
174
175
  * The client supplies `Clock.currentTimeMillis`, which is the `TestClock`
175
176
  * under test.
177
+ *
178
+ * The kept `cause` is a credential-free copy of the throwable, not the
179
+ * throwable itself. octokit redacts only the `authorization` credential and
180
+ * two query values; the copy also drops the request body (a webhook secret,
181
+ * GraphQL variables), the per-request options and the response body beyond
182
+ * its top-level `message` and `documentation_url`, drops `set-cookie`, and
183
+ * reads `REDACTED` for every other credential-bearing header. It keeps the
184
+ * prototype (so `instanceof` still answers), `name`, `message`, `status`,
185
+ * the method, the redacted url and the remaining headers. A throwable with
186
+ * no `request`, `response` or `headers` field is kept as it is.
176
187
  */
177
188
  static fromOctokit(operation, error, nowMillis) {
178
- return fromFacts(operation, readThrowable(error), nowMillis, error);
189
+ return fromFacts(operation, readThrowable(error), nowMillis, scrubThrowable(error));
179
190
  }
180
191
  /**
181
192
  * Classify a raw HTTP response: a status, its headers and its JSON body.
package/GitHubRelease.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { GitHubError } from "./GitHubError.js";
2
+ import { originOf, uploadChannelOf, uploadsBaseUrlFor } from "./internal/uploads.js";
2
3
  import { unstubbed } from "./internal/unstubbed.js";
3
4
  import { GitHubClient } from "./GitHubClient.js";
4
5
  import { Repo } from "./Repo.js";
@@ -19,7 +20,15 @@ var ReleaseInfo = class extends Schema.Class("ReleaseInfo")({
19
20
  prerelease: Schema.Boolean,
20
21
  /** The web URL. */
21
22
  url: Schema.String,
22
- /** The templated upload endpoint GitHub hands back for assets. */
23
+ /**
24
+ * The templated upload endpoint GitHub hands back for assets.
25
+ *
26
+ * @remarks
27
+ * {@link GitHubReleaseShape.uploadAsset} never routes by this value: it is
28
+ * response data. It only cross-checks that its origin is the upload host
29
+ * derived from the client's configured base URL, and refuses the upload
30
+ * when it is not.
31
+ */
23
32
  uploadUrl: Schema.String
24
33
  }) {};
25
34
  /**
@@ -172,16 +181,26 @@ const make = (client) => {
172
181
  release: release.id,
173
182
  asset: asset.name
174
183
  });
175
- const raw = yield* client.requestDecoded(asset.label === void 0 ? "POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name}" : "POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name,label}", {
184
+ const channel = uploadChannelOf(client);
185
+ const uploadsBaseUrl = uploadsBaseUrlFor(channel?.baseUrl);
186
+ const expected = originOf(uploadsBaseUrl);
187
+ const offered = originOf(release.uploadUrl);
188
+ if (expected === void 0 || offered !== expected) return yield* Effect.fail(new GitHubError({
189
+ kind: "rejected",
190
+ operation: "GitHubRelease.uploadAsset",
191
+ reason: offered === void 0 ? `release ${release.id} has no absolute upload URL; refusing to upload` : `release ${release.id}'s upload URL is on ${offered}, not this client's upload host ${expected ?? uploadsBaseUrl}; refusing to send the token there`
192
+ }));
193
+ const route = asset.label === void 0 ? "POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name}" : "POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name,label}";
194
+ const params = {
176
195
  owner,
177
196
  repo,
178
197
  release_id: release.id,
179
198
  name: asset.name,
180
199
  ...asset.label === void 0 ? {} : { label: asset.label },
181
200
  data: asset.data,
182
- baseUrl: UPLOADS_BASE_URL,
183
201
  headers: { "content-type": asset.contentType }
184
- }, AssetResponse);
202
+ };
203
+ const raw = channel === void 0 ? yield* client.requestDecoded(route, params, AssetResponse) : yield* channel.upload(route, params, AssetResponse);
185
204
  return assetOf(raw);
186
205
  }),
187
206
  listAssets: Effect.fn("GitHubRelease.listAssets")(function* (id, options) {
@@ -199,7 +218,5 @@ const make = (client) => {
199
218
  })
200
219
  };
201
220
  };
202
- /** Release assets do not go to the API host. */
203
- const UPLOADS_BASE_URL = "https://uploads.github.com";
204
221
 
205
222
  export { GitHubRelease, ReleaseAsset, ReleaseInfo };