@coffre/core 0.5.1 → 0.6.1

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.
@@ -16,6 +16,12 @@
16
16
  * workflow_ref: 'acme/api/.github/workflows/ci.yml@refs/heads/main',
17
17
  * ref: 'refs/heads/main',
18
18
  * event_name: ['pull_request', 'push', 'workflow_dispatch'] } }
19
+ *
20
+ * A GitHub binding may also name `environment`, the GitHub environment its
21
+ * job runs in, so that the environment's protection rules (its required
22
+ * reviewers, the branches it deploys) gate every run that signs in. GitHub
23
+ * changes `sub` for such a job, `repo:acme/api:environment:production`, but
24
+ * no GitHub binding matches `sub`: `ref` and `workflow_ref` stay as they were.
19
25
  */
20
26
  export declare const GITHUB_ISSUER = "https://token.actions.githubusercontent.com";
21
27
  export declare const GITLAB_ISSUER = "https://gitlab.com";
@@ -94,10 +100,14 @@ export declare function claimValues(value: ClaimValue | undefined): string[];
94
100
  /**
95
101
  * The claims of a verified token that differ from what a binding expects,
96
102
  * by name: none when it matches. A claim matches when the token carries its
97
- * value, or one of its values. A GitHub `pull_request` run, at
98
- * `refs/pull/<n>/merge`, counts as a run at the branch it merges into: its
99
- * `ref`, and the ref its `workflow_ref` ends in, are read as
100
- * `refs/heads/<base_ref>`.
103
+ * value, or one of its values. On GitHub's profiles, two readings:
104
+ *
105
+ * - A `pull_request` run, at `refs/pull/<n>/merge`, counts as a run at the
106
+ * branch it merges into: its `ref`, and the ref its `workflow_ref` ends
107
+ * in, are read as `refs/heads/<base_ref>`.
108
+ * - `environment` matches whatever its case: GitHub's environment names are
109
+ * case-insensitive, so `Production` and `production` are one environment,
110
+ * under the same protection rules.
101
111
  */
102
112
  export declare function differingClaims(profile: WorkloadProfile, expected: BindingClaims, token: Record<string, unknown>): string[];
103
113
  /** The claims as the MAC and the database hold them: JSON, sorted by name. */
@@ -129,18 +139,24 @@ export declare function workflowRef(repository: string, file: string, ref: strin
129
139
  * which one was meant.
130
140
  */
131
141
  export type Starts<One extends string, Many extends string> = ({ [K in One]: string; } & { [K in Many]?: never; }) | ({ [K in Many]: string[]; } & { [K in One]?: never; });
132
- /** A workflow of the repository, run at `ref` by any of `events`; a pull request, by the branch it merges into. */
142
+ /**
143
+ * A workflow of the repository, run at `ref` by any of `events`; a pull
144
+ * request, by the branch it merges into. An `environment` narrows it to the
145
+ * jobs that run in that GitHub environment.
146
+ */
133
147
  export declare function githubWorkflow(input: {
134
148
  repository: string;
135
149
  repositoryId: string;
136
150
  ownerId: string;
137
151
  workflow: string;
138
152
  ref: string;
153
+ environment?: string;
139
154
  } & Starts<'event', 'events'>): Omit<BindingPolicy, 'issuer'>;
140
155
  /**
141
156
  * A reusable workflow at one commit, called at `ref`: from one repository,
142
157
  * or, with `repositoryId` null, from any repository of the organization. A
143
- * `caller` workflow narrows it to one calling workflow.
158
+ * `caller` workflow narrows it to one calling workflow, and an
159
+ * `environment` to the jobs that run in that GitHub environment.
144
160
  */
145
161
  export declare function githubReusable(input: {
146
162
  repositoryId: string | null;
@@ -152,6 +168,7 @@ export declare function githubReusable(input: {
152
168
  repository: string;
153
169
  workflow: string;
154
170
  };
171
+ environment?: string;
155
172
  } & Starts<'event', 'events'>): Omit<BindingPolicy, 'issuer'>;
156
173
  /** A GitLab project's pipelines from any of `sources`, at a branch or tag. */
157
174
  export declare function gitlabProject(input: {
package/dist/workloads.js CHANGED
@@ -16,6 +16,12 @@
16
16
  * workflow_ref: 'acme/api/.github/workflows/ci.yml@refs/heads/main',
17
17
  * ref: 'refs/heads/main',
18
18
  * event_name: ['pull_request', 'push', 'workflow_dispatch'] } }
19
+ *
20
+ * A GitHub binding may also name `environment`, the GitHub environment its
21
+ * job runs in, so that the environment's protection rules (its required
22
+ * reviewers, the branches it deploys) gate every run that signs in. GitHub
23
+ * changes `sub` for such a job, `repo:acme/api:environment:production`, but
24
+ * no GitHub binding matches `sub`: `ref` and `workflow_ref` stay as they were.
19
25
  */
20
26
  const GITHUB_ISSUER = "https://token.actions.githubusercontent.com";
21
27
  const GITLAB_ISSUER = "https://gitlab.com";
@@ -135,6 +141,13 @@ const DIGITS = /^[1-9][0-9]{0,19}$/;
135
141
  const SHA = /^[0-9a-f]{40}$/;
136
142
  const REF = /^refs\/(heads|tags)\/\S{1,200}$/;
137
143
  const WORKFLOW = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[^@\s]+\.ya?ml@\S+$/;
144
+ /**
145
+ * A GitHub environment's name, as GitHub takes one: at most 255 characters,
146
+ * none a control character, and no space at either end, which GitHub would
147
+ * not keep. GitHub documents no narrower set: `production`, `Staging EU`
148
+ * and `prod/eu:1` are all names.
149
+ */
150
+ const ENVIRONMENT = /^(?! )[^\p{Cc}]{1,255}(?<! )$/u;
138
151
  /** A binding that breaks a rule, said so an owner can fix it. */
139
152
  var BindingInvalid = class extends Error {
140
153
  constructor(message) {
@@ -230,6 +243,7 @@ function checkGitHub(profile, claims) {
230
243
  const value = claims[id];
231
244
  if (value !== void 0 && !DIGITS.test(value)) throw new BindingInvalid(`${id} is GitHub's numeric ID, not a name`);
232
245
  }
246
+ if (claims.environment !== void 0 && !ENVIRONMENT.test(claims.environment)) throw new BindingInvalid("environment is a GitHub environment's name: at most 255 characters, no control character, no space at either end");
233
247
  const events = claimValues(claims.event_name);
234
248
  const ref = claims.ref;
235
249
  if (!REF.test(ref)) throw new BindingInvalid("ref is a full ref: refs/heads/<branch> or refs/tags/<tag>");
@@ -254,16 +268,23 @@ function checkGitLab(claims) {
254
268
  /**
255
269
  * The claims of a verified token that differ from what a binding expects,
256
270
  * by name: none when it matches. A claim matches when the token carries its
257
- * value, or one of its values. A GitHub `pull_request` run, at
258
- * `refs/pull/<n>/merge`, counts as a run at the branch it merges into: its
259
- * `ref`, and the ref its `workflow_ref` ends in, are read as
260
- * `refs/heads/<base_ref>`.
271
+ * value, or one of its values. On GitHub's profiles, two readings:
272
+ *
273
+ * - A `pull_request` run, at `refs/pull/<n>/merge`, counts as a run at the
274
+ * branch it merges into: its `ref`, and the ref its `workflow_ref` ends
275
+ * in, are read as `refs/heads/<base_ref>`.
276
+ * - `environment` matches whatever its case: GitHub's environment names are
277
+ * case-insensitive, so `Production` and `production` are one environment,
278
+ * under the same protection rules.
261
279
  */
262
280
  function differingClaims(profile, expected, token) {
263
- const claims = profile.startsWith("github") ? atBaseBranch(token) : token;
281
+ const github = profile.startsWith("github");
282
+ const claims = github ? atBaseBranch(token) : token;
264
283
  return Object.entries(expected).filter(([name, value]) => {
265
284
  const actual = claims[name];
266
- return typeof actual !== "string" || !claimValues(value).includes(actual);
285
+ if (typeof actual !== "string") return true;
286
+ if (github && name === "environment") return actual.toLowerCase() !== value.toLowerCase();
287
+ return !claimValues(value).includes(actual);
267
288
  }).map(([name]) => name);
268
289
  }
269
290
  function atBaseBranch(claims) {
@@ -329,7 +350,11 @@ function started(input, one, many) {
329
350
  if (list === void 0) throw new BindingInvalid(`name ${one} or ${many}`);
330
351
  return list;
331
352
  }
332
- /** A workflow of the repository, run at `ref` by any of `events`; a pull request, by the branch it merges into. */
353
+ /**
354
+ * A workflow of the repository, run at `ref` by any of `events`; a pull
355
+ * request, by the branch it merges into. An `environment` narrows it to the
356
+ * jobs that run in that GitHub environment.
357
+ */
333
358
  function githubWorkflow(input) {
334
359
  return {
335
360
  profile: "github",
@@ -338,14 +363,20 @@ function githubWorkflow(input) {
338
363
  repository_id: input.repositoryId,
339
364
  workflow_ref: workflowRef(input.repository, input.workflow, input.ref),
340
365
  ref: input.ref,
341
- event_name: started(input, "event", "events")
366
+ event_name: started(input, "event", "events"),
367
+ ...inEnvironment(input.environment)
342
368
  }
343
369
  };
344
370
  }
371
+ /** The `environment` claim, when one is named. */
372
+ function inEnvironment(environment) {
373
+ return environment === void 0 ? {} : { environment };
374
+ }
345
375
  /**
346
376
  * A reusable workflow at one commit, called at `ref`: from one repository,
347
377
  * or, with `repositoryId` null, from any repository of the organization. A
348
- * `caller` workflow narrows it to one calling workflow.
378
+ * `caller` workflow narrows it to one calling workflow, and an
379
+ * `environment` to the jobs that run in that GitHub environment.
349
380
  */
350
381
  function githubReusable(input) {
351
382
  const claims = {
@@ -354,7 +385,8 @@ function githubReusable(input) {
354
385
  ref: input.ref,
355
386
  event_name: started(input, "event", "events"),
356
387
  job_workflow_ref: input.called,
357
- job_workflow_sha: input.sha
388
+ job_workflow_sha: input.sha,
389
+ ...inEnvironment(input.environment)
358
390
  };
359
391
  if (input.caller !== void 0) claims.workflow_ref = workflowRef(input.caller.repository, input.caller.workflow, input.ref);
360
392
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/core",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "What coffre's packages share: roles, envelopes, identity, the audit chain and the vault's contract.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -84,7 +84,7 @@
84
84
  "@types/node": "26.1.1",
85
85
  "tsdown": "0.23.0",
86
86
  "typescript": "5.9.3",
87
- "@coffre/conformance": "0.5.1"
87
+ "@coffre/conformance": "0.6.1"
88
88
  },
89
89
  "scripts": {
90
90
  "build": "tsdown",
@@ -184,7 +184,7 @@ function holdsAlready(
184
184
  at: Place,
185
185
  ): boolean {
186
186
  const lasting = held.filter((other) => outlasts(other.expiresAt, grant.expiresAt));
187
- return ROLES[grant.role].permissions.every((permission) => allows({ isRootAdmin: false, role, scope, grants: lasting }, permission, at));
187
+ return ROLES[grant.role].permissions.every((permission) => allows({ role, scope, grants: lasting }, permission, at));
188
188
  }
189
189
 
190
190
  /** Whether `role` holds nothing `wider` does not. */
package/src/access.ts CHANGED
@@ -96,7 +96,7 @@ export function assignableToEnvironment(role: Role): boolean {
96
96
  export const INSTANCE_ROLES = {
97
97
  member: {
98
98
  name: 'Member',
99
- description: 'Only what projects grant.',
99
+ description: 'None.',
100
100
  permissions: [],
101
101
  },
102
102
  auditor: {
@@ -116,7 +116,7 @@ export const INSTANCE_ROLES = {
116
116
  },
117
117
  owner: {
118
118
  name: 'Owner',
119
- description: 'Everything an admin does, and read every secret.',
119
+ description: 'Everything, every secret included.',
120
120
  permissions: [...PERMISSIONS],
121
121
  },
122
122
  } as const satisfies Record<string, {
@@ -142,6 +142,16 @@ export function administers(role: InstanceRole): boolean {
142
142
  return role === 'admin' || role === 'owner';
143
143
  }
144
144
 
145
+ /**
146
+ * Whether a role and its scope make someone an Owner of the whole
147
+ * instance: the one standing the instance always keeps at least one of,
148
+ * and the one its bootstrap gives while it has none. An Owner scoped to
149
+ * `market` owns market, not the instance.
150
+ */
151
+ export function ownsInstance(standing: { role: InstanceRole; scope: Scope }): boolean {
152
+ return standing.role === 'owner' && unscoped(standing.scope);
153
+ }
154
+
145
155
  /**
146
156
  * Which projects, or environments, a scope takes in: all of them, only
147
157
  * those listed, or all but those listed. Projects are listed by id, so a
@@ -311,8 +321,6 @@ export function isCredentialScope(value: unknown): value is CredentialScope {
311
321
  * cannot drift apart.
312
322
  */
313
323
  export type Holdings = {
314
- /** Named in the vault's configuration: everything, everywhere. */
315
- isRootAdmin: boolean;
316
324
  /** `member` for service accounts, and for anyone not active. */
317
325
  role: InstanceRole;
318
326
  scope: Scope;
@@ -323,7 +331,7 @@ export type Holdings = {
323
331
  };
324
332
 
325
333
  /** Someone who holds nothing: a stranger, a removed member, a tampered row. */
326
- export const NOTHING: Holdings = { isRootAdmin: false, role: 'member', scope: EVERYWHERE, grants: [] };
334
+ export const NOTHING: Holdings = { role: 'member', scope: EVERYWHERE, grants: [] };
327
335
 
328
336
  /**
329
337
  * Whether a grant at `grant` reaches `place`:
@@ -353,7 +361,6 @@ export function covers(grant: GrantPlace, place: Place): boolean {
353
361
  */
354
362
  export function allows(holder: Holdings, permission: Permission, place: Place): boolean {
355
363
  if (holder.credentialScope != null && !holdsAt(holder.credentialScope, permission, place)) return false;
356
- if (holder.isRootAdmin) return true;
357
364
  if (instanceRoleGrants(holder.role, permission) && inScope(holder.scope, place)) return true;
358
365
  return holdsAt(holder.grants, permission, place);
359
366
  }
@@ -374,14 +381,14 @@ export function mayManageAccess(actor: Holdings, place: Place): boolean {
374
381
  }
375
382
 
376
383
  /**
377
- * Whether the holder runs the instance: a root admin, or an Admin or Owner
378
- * whose scope narrows nothing. They add and remove people, set instance
384
+ * Whether the holder runs the instance: an Admin or Owner whose scope
385
+ * narrows nothing. They add and remove people, set instance
379
386
  * roles, and change the instance's settings; they read its whole log too
380
387
  * (`readsWholeLog`). A scoped Admin manages only inside its scope, so it
381
388
  * does none of these.
382
389
  */
383
390
  export function runsInstance(holder: Holdings): boolean {
384
- return holder.isRootAdmin || (administers(holder.role) && unscoped(holder.scope));
391
+ return administers(holder.role) && unscoped(holder.scope);
385
392
  }
386
393
 
387
394
  /**
@@ -463,12 +470,12 @@ export function issuesCredential(
463
470
  * Whether the holder reads the whole log, the instance's own entries
464
471
  * (sign-ins, people, service accounts, settings) with every project's, and
465
472
  * verifies it: whoever holds `audit.read` by an instance role whose scope
466
- * narrows nothing, an Auditor's included, and a root admin. Reading, not
473
+ * narrows nothing, an Auditor's included. Reading, not
467
474
  * running: an Auditor of the whole instance changes nothing it reads about.
468
475
  * Anyone else reads the places they hold `audit.read` in.
469
476
  */
470
477
  export function readsWholeLog(holder: Holdings): boolean {
471
- return holder.isRootAdmin || (instanceRoleGrants(holder.role, 'audit.read') && unscoped(holder.scope));
478
+ return instanceRoleGrants(holder.role, 'audit.read') && unscoped(holder.scope);
472
479
  }
473
480
 
474
481
  /**
@@ -478,7 +485,6 @@ export function readsWholeLog(holder: Holdings): boolean {
478
485
  * hold the project around them.
479
486
  */
480
487
  export function makesProjects(holder: Holdings): boolean {
481
- if (holder.isRootAdmin) return true;
482
488
  const { projects, environments } = holder.scope;
483
489
  return administers(holder.role) && environments === 'all' && (projects === 'all' || 'except' in projects);
484
490
  }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * The deployment's own GitHub App, made once with `githubApp(…)` and passed
3
+ * to each side that uses it (docs/github-app.md):
4
+ *
5
+ * const app = githubApp({ appId, privateKey, clientId, clientSecret });
6
+ * signin({ providers: [github({ app })], workloads: { limits, github: app } })
7
+ *
8
+ * Sign-in uses its OAuth client, `clientId` and `clientSecret`, as it would
9
+ * an OAuth app's (`github({ app })`). The forge side, `workloads.github`,
10
+ * uses its ID and private key: it lists the repositories the app is
11
+ * installed on, private ones too, where an owner trusts a workflow, and
12
+ * resolves `owner/name` to the IDs a binding names, with Metadata:
13
+ * read-only, so what it reaches there is the names and IDs of those
14
+ * repositories. Either side may go without the other: `github({ clientId,
15
+ * clientSecret })` signs in alone, and an app with no client lists alone.
16
+ *
17
+ * The app's private key lives with the app, the rest of the deployment's
18
+ * configuration, not in the vault: it opens no secret, and the vault's
19
+ * isolation is for the keys that do. It and the client secret stay in this
20
+ * object's private fields, and no message, log line or answer carries them,
21
+ * the JWTs it signs or the installation tokens they buy.
22
+ *
23
+ * The object lives as long as its configuration: the isolate's, on Workers,
24
+ * or the process's. It keeps settled values only (AGENTS.md, "What an
25
+ * isolate keeps"): the imported key, the app's name, and each
26
+ * installation's token until shortly before it expires. Concurrent first
27
+ * calls each import and ask for their own.
28
+ */
29
+
30
+ export type GitHubAppOptions = {
31
+ /** The app's ID, on its settings page ("App ID"): a number, not its client ID. */
32
+ appId: string | number;
33
+ /** A private key generated on its settings page: the PEM file, as downloaded. */
34
+ privateKey: string;
35
+ /** Its OAuth client, on the same page: for signing people in with it, `github({ app })`. Both, or neither. */
36
+ clientId?: string;
37
+ clientSecret?: string;
38
+ /** https://github.com, or a GitHub Enterprise Server host: where people sign in. */
39
+ webUrl?: string;
40
+ /**
41
+ * https://api.github.com, or `${host}/api/v3` on Enterprise Server. The
42
+ * forge side takes github.com's only, or the dev IdP's on loopback: an
43
+ * Enterprise Server's runs sign their tokens under another issuer than
44
+ * the one its lookups bind to.
45
+ */
46
+ apiUrl?: string;
47
+ };
48
+
49
+ /** A repository, by the IDs a binding names: what `GET /workloads/lookup` answers. */
50
+ export type GitHubRepository = { github: string; repositoryId: string; ownerId: string; private: boolean };
51
+
52
+ /** What the app reaches: its name and where to install it, the accounts it is installed on, and their repositories. */
53
+ export type GitHubAppRepositories = {
54
+ app: { name: string; installUrl: string };
55
+ /** The accounts it is installed on, by login. */
56
+ installations: string[];
57
+ /** By name. */
58
+ repositories: GitHubRepository[];
59
+ /** More than one listing reaches: the others are typed by name. */
60
+ truncated: boolean;
61
+ };
62
+
63
+ /** GitHub could not be asked, or refused the app: the message says which, for a person, and carries no credential. */
64
+ export class GitHubAppUnavailable extends Error {
65
+ constructor(message: string, options?: { cause?: unknown }) {
66
+ super(message, options);
67
+ this.name = 'GitHubAppUnavailable';
68
+ }
69
+ }
70
+
71
+ /** GitHub answers 100 per page at most. */
72
+ const PAGE = 100;
73
+ /** Requests one listing makes at most, tokens included: well under a Worker's subrequest limit. */
74
+ export const MAX_LISTING_REQUESTS = 40;
75
+ /** A token GitHub issues for an hour is used until this long before it ends, so that none expires mid-call. */
76
+ export const TOKEN_MARGIN_MS = 5 * 60 * 1000;
77
+ /** Each request, from DNS to the last byte. */
78
+ const DEADLINE_MS = 10_000;
79
+
80
+ type Installation = { id: number; account: string };
81
+ type Token = { token: string; expiresAt: number };
82
+
83
+ export class GitHubApp {
84
+ readonly appId: string;
85
+ readonly webUrl: string;
86
+ readonly apiUrl: string;
87
+ /** Its OAuth client's ID, which is no secret; null when it was given none. */
88
+ readonly clientId: string | null;
89
+ readonly #clientSecret: string | null;
90
+ readonly #pkcs8: Uint8Array<ArrayBuffer>;
91
+ readonly #fetch: typeof fetch;
92
+ readonly #now: () => number;
93
+ #key: CryptoKey | null = null;
94
+ #app: GitHubAppRepositories['app'] | null = null;
95
+ readonly #tokens = new Map<number, Token>();
96
+
97
+ constructor(
98
+ options: { appId: string; apiUrl: string; webUrl?: string; pkcs8: Uint8Array<ArrayBuffer>; client?: { clientId: string; clientSecret: string } },
99
+ io: { fetch?: typeof fetch; now?: () => number } = {},
100
+ ) {
101
+ this.appId = options.appId;
102
+ this.webUrl = options.webUrl ?? 'https://github.com';
103
+ this.apiUrl = options.apiUrl;
104
+ this.clientId = options.client?.clientId ?? null;
105
+ this.#clientSecret = options.client?.clientSecret ?? null;
106
+ this.#pkcs8 = options.pkcs8;
107
+ this.#fetch = io.fetch ?? ((input, init) => fetch(input, init));
108
+ this.#now = io.now ?? Date.now;
109
+ }
110
+
111
+ /** Its OAuth client, for `github({ app })`; null when it was given none. */
112
+ signinClient(): { clientId: string; clientSecret: string } | null {
113
+ return this.clientId === null || this.#clientSecret === null ? null : { clientId: this.clientId, clientSecret: this.#clientSecret };
114
+ }
115
+
116
+ /** Every repository of every account the app is installed on, as many as `MAX_LISTING_REQUESTS` reach. */
117
+ async repositories(): Promise<GitHubAppRepositories> {
118
+ // Every request the listing makes spends from it, tokens and retries included.
119
+ const budget: Budget = { left: MAX_LISTING_REQUESTS };
120
+ this.#app ??= await this.#appInfo(budget);
121
+ const { installations, more } = await this.#installations(budget);
122
+ let truncated = more;
123
+ /** An installation's repositories, its first page paid from `own`, and the rest from what the listing has left. */
124
+ const list = async (installation: Installation, own: Budget): Promise<GitHubRepository[]> => {
125
+ const repositories: GitHubRepository[] = [];
126
+ try {
127
+ for (let page = 1; ; page++) {
128
+ const body = record(await this.#asInstallation(installation.id, `/installation/repositories?per_page=${PAGE}&page=${page}`, { budget: own }));
129
+ const items = Array.isArray(body.repositories) ? body.repositories : [];
130
+ repositories.push(...items.flatMap((item) => repository(item) ?? []));
131
+ const total = typeof body.total_count === 'number' ? body.total_count : 0;
132
+ if (items.length < PAGE || page * PAGE >= total) return repositories;
133
+ }
134
+ } catch (error) {
135
+ if (error !== SPENT) throw error;
136
+ truncated = true;
137
+ return repositories;
138
+ }
139
+ };
140
+ // In waves, at once within each: every installation of a wave reserves what its first page can cost at
141
+ // worst, a token and the page, or a kept token refused, a new one and the page again, so that each one
142
+ // started finishes its first page. What a wave did not spend goes to the next.
143
+ const listed: GitHubRepository[][] = [];
144
+ for (let rest = installations; rest.length > 0; ) {
145
+ const wave: { installation: Installation; own: Budget }[] = [];
146
+ for (const installation of rest) {
147
+ const worst = this.#kept(installation.id) === null ? 2 : 3;
148
+ if (budget.left < worst) break;
149
+ budget.left -= worst;
150
+ wave.push({ installation, own: { left: worst, then: budget } });
151
+ }
152
+ if (wave.length === 0) {
153
+ truncated = true;
154
+ break;
155
+ }
156
+ rest = rest.slice(wave.length);
157
+ listed.push(...(await Promise.all(wave.map(({ installation, own }) => list(installation, own)))));
158
+ for (const { own } of wave) budget.left += own.left;
159
+ }
160
+ const byId = new Map(listed.flat().map((each) => [each.repositoryId, each]));
161
+ return {
162
+ app: this.#app,
163
+ installations: installations.map(({ account }) => account),
164
+ repositories: [...byId.values()].sort((a, b) => a.github.localeCompare(b.github)),
165
+ truncated,
166
+ };
167
+ }
168
+
169
+ /** `owner/name`'s IDs, when the app is installed on it; null when it is not, its installation is suspended, or GitHub knows no such repository. */
170
+ async repository(fullName: string): Promise<GitHubRepository | null> {
171
+ const { id, suspended_at: suspended } = record(await this.#asApp(`/repos/${fullName}/installation`, { missing: true }));
172
+ if (typeof id !== 'number' || (suspended !== null && suspended !== undefined)) return null;
173
+ return repository(await this.#asInstallation(id, `/repos/${fullName}`, { missing: true }));
174
+ }
175
+
176
+ async #appInfo(budget: Budget): Promise<GitHubAppRepositories['app']> {
177
+ const app = record(await this.#asApp('/app', { budget }));
178
+ if (typeof app.name !== 'string' || typeof app.html_url !== 'string') throw new GitHubAppUnavailable('GitHub answered /app without the app');
179
+ return { name: app.name, installUrl: `${app.html_url}/installations/new` };
180
+ }
181
+
182
+ /** The installations not suspended, of the first page: `more` when there may be others. */
183
+ async #installations(budget: Budget): Promise<{ installations: Installation[]; more: boolean }> {
184
+ const answer = await this.#asApp(`/app/installations?per_page=${PAGE}`, { budget });
185
+ const items = Array.isArray(answer) ? answer : [];
186
+ const installations = items.flatMap((item): Installation[] => {
187
+ const { id, account, suspended_at: suspended } = record(item);
188
+ const login = record(account).login;
189
+ return typeof id === 'number' && typeof login === 'string' && (suspended === null || suspended === undefined) ? [{ id, account: login }] : [];
190
+ });
191
+ return { installations: installations.sort((a, b) => a.account.localeCompare(b.account)), more: items.length >= PAGE };
192
+ }
193
+
194
+ async #asApp(path: string, options: { missing?: boolean; budget?: Budget } = {}): Promise<unknown> {
195
+ return this.#call(path, { method: 'GET', token: await this.#jwt(), ...options });
196
+ }
197
+
198
+ /** As the installation, with its token; once more with a fresh one should GitHub refuse the one kept (the app was reinstalled, say). */
199
+ async #asInstallation(id: number, path: string, options: { missing?: boolean; budget?: Budget } = {}): Promise<unknown> {
200
+ const kept = this.#kept(id);
201
+ const fresh = kept === null;
202
+ const token = kept ?? (await this.#issueToken(id, options.budget));
203
+ const answer = await this.#call(path, { method: 'GET', token, ...options, refused: !fresh });
204
+ if (answer !== REFUSED) return answer;
205
+ return this.#call(path, { method: 'GET', token: await this.#issueToken(id, options.budget), ...options });
206
+ }
207
+
208
+ /** The installation's token, while it has more than `TOKEN_MARGIN_MS` to live. */
209
+ #kept(id: number): string | null {
210
+ const kept = this.#tokens.get(id);
211
+ return kept !== undefined && kept.expiresAt - TOKEN_MARGIN_MS > this.#now() ? kept.token : null;
212
+ }
213
+
214
+ /** https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app */
215
+ async #issueToken(id: number, budget?: Budget): Promise<string> {
216
+ this.#tokens.delete(id);
217
+ const answer = record(await this.#call(`/app/installations/${id}/access_tokens`, { method: 'POST', token: await this.#jwt(), budget }));
218
+ const expiresAt = typeof answer.expires_at === 'string' ? Date.parse(answer.expires_at) : NaN;
219
+ if (typeof answer.token !== 'string' || !Number.isFinite(expiresAt)) throw new GitHubAppUnavailable('GitHub issued no installation token');
220
+ this.#tokens.set(id, { token: answer.token, expiresAt });
221
+ return answer.token;
222
+ }
223
+
224
+ /**
225
+ * The app's JWT: RS256 over its ID, for nine minutes, issued a minute
226
+ * back for a clock running ahead (GitHub refuses more than ten).
227
+ * https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
228
+ */
229
+ async #jwt(): Promise<string> {
230
+ const key = this.#key ?? (this.#key = await importKey(this.#pkcs8));
231
+ const now = Math.floor(this.#now() / 1000);
232
+ const input = `${base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))}.${base64url(JSON.stringify({ iat: now - 60, exp: now + 540, iss: this.appId }))}`;
233
+ const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(input));
234
+ return `${input}.${base64url(new Uint8Array(signature))}`;
235
+ }
236
+
237
+ /**
238
+ * One request, spent from `budget` when it has one, and thrown as
239
+ * `SPENT` when none is left. A 404 is null where `missing` allows it; a
240
+ * 401 is `REFUSED` where `refused` asks, for a token to be issued again;
241
+ * any other answer than a 2xx says GitHub's status and the path, and a
242
+ * redirect is never followed.
243
+ */
244
+ async #call(path: string, request: { method: 'GET' | 'POST'; token: string; missing?: boolean; refused?: boolean; budget?: Budget }): Promise<unknown> {
245
+ if (request.budget !== undefined && !spend(request.budget)) throw SPENT;
246
+ let response: Response;
247
+ try {
248
+ response = await this.#fetch(`${this.apiUrl}${path}`, {
249
+ method: request.method,
250
+ redirect: 'manual',
251
+ signal: AbortSignal.timeout(DEADLINE_MS),
252
+ headers: {
253
+ accept: 'application/vnd.github+json',
254
+ authorization: `Bearer ${request.token}`,
255
+ 'user-agent': 'coffre',
256
+ 'x-github-api-version': '2022-11-28',
257
+ },
258
+ });
259
+ } catch (cause) {
260
+ throw new GitHubAppUnavailable('GitHub could not be reached', { cause });
261
+ }
262
+ if (response.ok) {
263
+ try {
264
+ return await response.json();
265
+ } catch {
266
+ throw new GitHubAppUnavailable(`GitHub answered ${path.split('?')[0]} with no JSON`);
267
+ }
268
+ }
269
+ await response.body?.cancel();
270
+ if (response.status === 404 && request.missing === true) return null;
271
+ if (response.status === 401 && request.refused === true) return REFUSED;
272
+ if (response.status === 401) throw new GitHubAppUnavailable(`GitHub refused the app (401): check its app ID and private key`);
273
+ throw new GitHubAppUnavailable(`GitHub answered ${path.split('?')[0]} with ${response.status}`);
274
+ }
275
+ }
276
+
277
+ /** A kept token GitHub refused. */
278
+ const REFUSED = Symbol('refused');
279
+ /** A listing's requests, left to make: an installation's own reserve first, then the listing's. */
280
+ type Budget = { left: number; then?: Budget };
281
+
282
+ function spend(budget: Budget | undefined): boolean {
283
+ for (let at = budget; at !== undefined; at = at.then) {
284
+ if (at.left > 0) {
285
+ at.left--;
286
+ return true;
287
+ }
288
+ }
289
+ return false;
290
+ }
291
+ /** None left: the listing stops there, and says it is partial. */
292
+ const SPENT = Symbol('spent');
293
+
294
+ /**
295
+ * A private key's PEM as PKCS #8, which Web Crypto imports: GitHub's
296
+ * download is PKCS #1 (`BEGIN RSA PRIVATE KEY`), wrapped here as an RSA
297
+ * key's PKCS #8. Escaped newlines, as an environment variable sometimes
298
+ * holds them, are newlines. Never says what the key was.
299
+ */
300
+ export function pkcs8Of(pem: string): Uint8Array<ArrayBuffer> {
301
+ const text = typeof pem === 'string' ? pem.replaceAll('\\n', '\n').trim() : '';
302
+ const match = /^-----BEGIN (RSA )?PRIVATE KEY-----\s*([A-Za-z0-9+/=\s]+?)\s*-----END \1PRIVATE KEY-----$/.exec(text);
303
+ if (match === null) {
304
+ throw new Error('githubApp(…).privateKey must be the app\'s private key, the PEM file GitHub downloads (BEGIN RSA PRIVATE KEY)');
305
+ }
306
+ const der = new Uint8Array(Buffer.from(match[2]!.replace(/\s+/g, ''), 'base64'));
307
+ if (match[1] === undefined) return der;
308
+ // PrivateKeyInfo: version 0, rsaEncryption with no parameters, and the PKCS #1 key as an octet string.
309
+ const algorithm = [0x02, 0x01, 0x00, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
310
+ const octets = [0x04, ...derLength(der.length)];
311
+ const body = algorithm.length + octets.length + der.length;
312
+ const out = new Uint8Array(new ArrayBuffer(1 + derLength(body).length + body));
313
+ out.set([0x30, ...derLength(body), ...algorithm, ...octets]);
314
+ out.set(der, out.length - der.length);
315
+ return out;
316
+ }
317
+
318
+ function derLength(length: number): number[] {
319
+ if (length < 0x80) return [length];
320
+ const bytes: number[] = [];
321
+ for (let rest = length; rest > 0; rest >>= 8) bytes.unshift(rest & 0xff);
322
+ return [0x80 | bytes.length, ...bytes];
323
+ }
324
+
325
+ async function importKey(pkcs8: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
326
+ try {
327
+ return await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
328
+ } catch {
329
+ throw new GitHubAppUnavailable('the GitHub App\'s private key is not an RSA private key');
330
+ }
331
+ }
332
+
333
+ function base64url(value: string | Uint8Array): string {
334
+ return Buffer.from(value).toString('base64url');
335
+ }
336
+
337
+ function record(value: unknown): Record<string, unknown> {
338
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
339
+ }
340
+
341
+ /** A repository as GitHub's API answers it, by the IDs a binding names. */
342
+ function repository(value: unknown): GitHubRepository | null {
343
+ const { id, full_name: name, owner, private: hidden } = record(value);
344
+ const ownerId = record(owner).id;
345
+ if (typeof name !== 'string' || !Number.isSafeInteger(id) || !Number.isSafeInteger(ownerId)) return null;
346
+ return { github: name, repositoryId: String(id), ownerId: String(ownerId), private: hidden === true };
347
+ }
@@ -1,5 +1,6 @@
1
1
  // `@coffre/core/identity`: who is asking, and how coffre's sign-in finds out.
2
2
  export * from './auth.ts';
3
+ export * from './github-app.ts';
3
4
  export * from './signin/index.ts';
4
5
  export * from './tokens.ts';
5
6
  export * from './types.ts';