@coffre/core 0.5.1 → 0.6.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/dist/identity.js CHANGED
@@ -2,6 +2,328 @@ import { BindingInvalid, EVENT_EXPOSURE, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_IS
2
2
  import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
3
3
  import * as oauth from "oauth4webapi";
4
4
  import { createLocalJWKSet, createRemoteJWKSet, errors, jwtVerify } from "jose";
5
+ //#region src/identity/github-app.ts
6
+ /** GitHub could not be asked, or refused the app: the message says which, for a person, and carries no credential. */
7
+ var GitHubAppUnavailable = class extends Error {
8
+ constructor(message, options) {
9
+ super(message, options);
10
+ this.name = "GitHubAppUnavailable";
11
+ }
12
+ };
13
+ /** GitHub answers 100 per page at most. */
14
+ const PAGE = 100;
15
+ /** Requests one listing makes at most, tokens included: well under a Worker's subrequest limit. */
16
+ const MAX_LISTING_REQUESTS = 40;
17
+ /** A token GitHub issues for an hour is used until this long before it ends, so that none expires mid-call. */
18
+ const TOKEN_MARGIN_MS = 3e5;
19
+ /** Each request, from DNS to the last byte. */
20
+ const DEADLINE_MS = 1e4;
21
+ var GitHubApp = class {
22
+ appId;
23
+ webUrl;
24
+ apiUrl;
25
+ /** Its OAuth client's ID, which is no secret; null when it was given none. */
26
+ clientId;
27
+ #clientSecret;
28
+ #pkcs8;
29
+ #fetch;
30
+ #now;
31
+ #key = null;
32
+ #app = null;
33
+ #tokens = /* @__PURE__ */ new Map();
34
+ constructor(options, io = {}) {
35
+ this.appId = options.appId;
36
+ this.webUrl = options.webUrl ?? "https://github.com";
37
+ this.apiUrl = options.apiUrl;
38
+ this.clientId = options.client?.clientId ?? null;
39
+ this.#clientSecret = options.client?.clientSecret ?? null;
40
+ this.#pkcs8 = options.pkcs8;
41
+ this.#fetch = io.fetch ?? ((input, init) => fetch(input, init));
42
+ this.#now = io.now ?? Date.now;
43
+ }
44
+ /** Its OAuth client, for `github({ app })`; null when it was given none. */
45
+ signinClient() {
46
+ return this.clientId === null || this.#clientSecret === null ? null : {
47
+ clientId: this.clientId,
48
+ clientSecret: this.#clientSecret
49
+ };
50
+ }
51
+ /** Every repository of every account the app is installed on, as many as `MAX_LISTING_REQUESTS` reach. */
52
+ async repositories() {
53
+ const budget = { left: 40 };
54
+ this.#app ??= await this.#appInfo(budget);
55
+ const { installations, more } = await this.#installations(budget);
56
+ let truncated = more;
57
+ /** An installation's repositories, its first page paid from `own`, and the rest from what the listing has left. */
58
+ const list = async (installation, own) => {
59
+ const repositories = [];
60
+ try {
61
+ for (let page = 1;; page++) {
62
+ const body = record(await this.#asInstallation(installation.id, `/installation/repositories?per_page=${PAGE}&page=${page}`, { budget: own }));
63
+ const items = Array.isArray(body.repositories) ? body.repositories : [];
64
+ repositories.push(...items.flatMap((item) => repository(item) ?? []));
65
+ const total = typeof body.total_count === "number" ? body.total_count : 0;
66
+ if (items.length < PAGE || page * PAGE >= total) return repositories;
67
+ }
68
+ } catch (error) {
69
+ if (error !== SPENT) throw error;
70
+ truncated = true;
71
+ return repositories;
72
+ }
73
+ };
74
+ const listed = [];
75
+ for (let rest = installations; rest.length > 0;) {
76
+ const wave = [];
77
+ for (const installation of rest) {
78
+ const worst = this.#kept(installation.id) === null ? 2 : 3;
79
+ if (budget.left < worst) break;
80
+ budget.left -= worst;
81
+ wave.push({
82
+ installation,
83
+ own: {
84
+ left: worst,
85
+ then: budget
86
+ }
87
+ });
88
+ }
89
+ if (wave.length === 0) {
90
+ truncated = true;
91
+ break;
92
+ }
93
+ rest = rest.slice(wave.length);
94
+ listed.push(...await Promise.all(wave.map(({ installation, own }) => list(installation, own))));
95
+ for (const { own } of wave) budget.left += own.left;
96
+ }
97
+ const byId = new Map(listed.flat().map((each) => [each.repositoryId, each]));
98
+ return {
99
+ app: this.#app,
100
+ installations: installations.map(({ account }) => account),
101
+ repositories: [...byId.values()].sort((a, b) => a.github.localeCompare(b.github)),
102
+ truncated
103
+ };
104
+ }
105
+ /** `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. */
106
+ async repository(fullName) {
107
+ const { id, suspended_at: suspended } = record(await this.#asApp(`/repos/${fullName}/installation`, { missing: true }));
108
+ if (typeof id !== "number" || suspended !== null && suspended !== void 0) return null;
109
+ return repository(await this.#asInstallation(id, `/repos/${fullName}`, { missing: true }));
110
+ }
111
+ async #appInfo(budget) {
112
+ const app = record(await this.#asApp("/app", { budget }));
113
+ if (typeof app.name !== "string" || typeof app.html_url !== "string") throw new GitHubAppUnavailable("GitHub answered /app without the app");
114
+ return {
115
+ name: app.name,
116
+ installUrl: `${app.html_url}/installations/new`
117
+ };
118
+ }
119
+ /** The installations not suspended, of the first page: `more` when there may be others. */
120
+ async #installations(budget) {
121
+ const answer = await this.#asApp(`/app/installations?per_page=${PAGE}`, { budget });
122
+ const items = Array.isArray(answer) ? answer : [];
123
+ return {
124
+ installations: items.flatMap((item) => {
125
+ const { id, account, suspended_at: suspended } = record(item);
126
+ const login = record(account).login;
127
+ return typeof id === "number" && typeof login === "string" && (suspended === null || suspended === void 0) ? [{
128
+ id,
129
+ account: login
130
+ }] : [];
131
+ }).sort((a, b) => a.account.localeCompare(b.account)),
132
+ more: items.length >= PAGE
133
+ };
134
+ }
135
+ async #asApp(path, options = {}) {
136
+ return this.#call(path, {
137
+ method: "GET",
138
+ token: await this.#jwt(),
139
+ ...options
140
+ });
141
+ }
142
+ /** As the installation, with its token; once more with a fresh one should GitHub refuse the one kept (the app was reinstalled, say). */
143
+ async #asInstallation(id, path, options = {}) {
144
+ const kept = this.#kept(id);
145
+ const fresh = kept === null;
146
+ const token = kept ?? await this.#issueToken(id, options.budget);
147
+ const answer = await this.#call(path, {
148
+ method: "GET",
149
+ token,
150
+ ...options,
151
+ refused: !fresh
152
+ });
153
+ if (answer !== REFUSED) return answer;
154
+ return this.#call(path, {
155
+ method: "GET",
156
+ token: await this.#issueToken(id, options.budget),
157
+ ...options
158
+ });
159
+ }
160
+ /** The installation's token, while it has more than `TOKEN_MARGIN_MS` to live. */
161
+ #kept(id) {
162
+ const kept = this.#tokens.get(id);
163
+ return kept !== void 0 && kept.expiresAt - 3e5 > this.#now() ? kept.token : null;
164
+ }
165
+ /** https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app */
166
+ async #issueToken(id, budget) {
167
+ this.#tokens.delete(id);
168
+ const answer = record(await this.#call(`/app/installations/${id}/access_tokens`, {
169
+ method: "POST",
170
+ token: await this.#jwt(),
171
+ budget
172
+ }));
173
+ const expiresAt = typeof answer.expires_at === "string" ? Date.parse(answer.expires_at) : NaN;
174
+ if (typeof answer.token !== "string" || !Number.isFinite(expiresAt)) throw new GitHubAppUnavailable("GitHub issued no installation token");
175
+ this.#tokens.set(id, {
176
+ token: answer.token,
177
+ expiresAt
178
+ });
179
+ return answer.token;
180
+ }
181
+ /**
182
+ * The app's JWT: RS256 over its ID, for nine minutes, issued a minute
183
+ * back for a clock running ahead (GitHub refuses more than ten).
184
+ * https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
185
+ */
186
+ async #jwt() {
187
+ const key = this.#key ?? (this.#key = await importKey(this.#pkcs8));
188
+ const now = Math.floor(this.#now() / 1e3);
189
+ const input = `${base64url(JSON.stringify({
190
+ alg: "RS256",
191
+ typ: "JWT"
192
+ }))}.${base64url(JSON.stringify({
193
+ iat: now - 60,
194
+ exp: now + 540,
195
+ iss: this.appId
196
+ }))}`;
197
+ const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(input));
198
+ return `${input}.${base64url(new Uint8Array(signature))}`;
199
+ }
200
+ /**
201
+ * One request, spent from `budget` when it has one, and thrown as
202
+ * `SPENT` when none is left. A 404 is null where `missing` allows it; a
203
+ * 401 is `REFUSED` where `refused` asks, for a token to be issued again;
204
+ * any other answer than a 2xx says GitHub's status and the path, and a
205
+ * redirect is never followed.
206
+ */
207
+ async #call(path, request) {
208
+ if (request.budget !== void 0 && !spend(request.budget)) throw SPENT;
209
+ let response;
210
+ try {
211
+ response = await this.#fetch(`${this.apiUrl}${path}`, {
212
+ method: request.method,
213
+ redirect: "manual",
214
+ signal: AbortSignal.timeout(DEADLINE_MS),
215
+ headers: {
216
+ accept: "application/vnd.github+json",
217
+ authorization: `Bearer ${request.token}`,
218
+ "user-agent": "coffre",
219
+ "x-github-api-version": "2022-11-28"
220
+ }
221
+ });
222
+ } catch (cause) {
223
+ throw new GitHubAppUnavailable("GitHub could not be reached", { cause });
224
+ }
225
+ if (response.ok) try {
226
+ return await response.json();
227
+ } catch {
228
+ throw new GitHubAppUnavailable(`GitHub answered ${path.split("?")[0]} with no JSON`);
229
+ }
230
+ await response.body?.cancel();
231
+ if (response.status === 404 && request.missing === true) return null;
232
+ if (response.status === 401 && request.refused === true) return REFUSED;
233
+ if (response.status === 401) throw new GitHubAppUnavailable(`GitHub refused the app (401): check its app ID and private key`);
234
+ throw new GitHubAppUnavailable(`GitHub answered ${path.split("?")[0]} with ${response.status}`);
235
+ }
236
+ };
237
+ /** A kept token GitHub refused. */
238
+ const REFUSED = Symbol("refused");
239
+ function spend(budget) {
240
+ for (let at = budget; at !== void 0; at = at.then) if (at.left > 0) {
241
+ at.left--;
242
+ return true;
243
+ }
244
+ return false;
245
+ }
246
+ /** None left: the listing stops there, and says it is partial. */
247
+ const SPENT = Symbol("spent");
248
+ /**
249
+ * A private key's PEM as PKCS #8, which Web Crypto imports: GitHub's
250
+ * download is PKCS #1 (`BEGIN RSA PRIVATE KEY`), wrapped here as an RSA
251
+ * key's PKCS #8. Escaped newlines, as an environment variable sometimes
252
+ * holds them, are newlines. Never says what the key was.
253
+ */
254
+ function pkcs8Of(pem) {
255
+ const text = typeof pem === "string" ? pem.replaceAll("\\n", "\n").trim() : "";
256
+ const match = /^-----BEGIN (RSA )?PRIVATE KEY-----\s*([A-Za-z0-9+/=\s]+?)\s*-----END \1PRIVATE KEY-----$/.exec(text);
257
+ if (match === null) throw new Error("githubApp(…).privateKey must be the app's private key, the PEM file GitHub downloads (BEGIN RSA PRIVATE KEY)");
258
+ const der = new Uint8Array(Buffer.from(match[2].replace(/\s+/g, ""), "base64"));
259
+ if (match[1] === void 0) return der;
260
+ const algorithm = [
261
+ 2,
262
+ 1,
263
+ 0,
264
+ 48,
265
+ 13,
266
+ 6,
267
+ 9,
268
+ 42,
269
+ 134,
270
+ 72,
271
+ 134,
272
+ 247,
273
+ 13,
274
+ 1,
275
+ 1,
276
+ 1,
277
+ 5,
278
+ 0
279
+ ];
280
+ const octets = [4, ...derLength(der.length)];
281
+ const body = algorithm.length + octets.length + der.length;
282
+ const out = new Uint8Array(new ArrayBuffer(1 + derLength(body).length + body));
283
+ out.set([
284
+ 48,
285
+ ...derLength(body),
286
+ ...algorithm,
287
+ ...octets
288
+ ]);
289
+ out.set(der, out.length - der.length);
290
+ return out;
291
+ }
292
+ function derLength(length) {
293
+ if (length < 128) return [length];
294
+ const bytes = [];
295
+ for (let rest = length; rest > 0; rest >>= 8) bytes.unshift(rest & 255);
296
+ return [128 | bytes.length, ...bytes];
297
+ }
298
+ async function importKey(pkcs8) {
299
+ try {
300
+ return await crypto.subtle.importKey("pkcs8", pkcs8, {
301
+ name: "RSASSA-PKCS1-v1_5",
302
+ hash: "SHA-256"
303
+ }, false, ["sign"]);
304
+ } catch {
305
+ throw new GitHubAppUnavailable("the GitHub App's private key is not an RSA private key");
306
+ }
307
+ }
308
+ function base64url(value) {
309
+ return Buffer.from(value).toString("base64url");
310
+ }
311
+ function record(value) {
312
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
313
+ }
314
+ /** A repository as GitHub's API answers it, by the IDs a binding names. */
315
+ function repository(value) {
316
+ const { id, full_name: name, owner, private: hidden } = record(value);
317
+ const ownerId = record(owner).id;
318
+ if (typeof name !== "string" || !Number.isSafeInteger(id) || !Number.isSafeInteger(ownerId)) return null;
319
+ return {
320
+ github: name,
321
+ repositoryId: String(id),
322
+ ownerId: String(ownerId),
323
+ private: hidden === true
324
+ };
325
+ }
326
+ //#endregion
5
327
  //#region src/identity/signin/types.ts
6
328
  var SigninError = class extends Error {
7
329
  code;
@@ -70,7 +392,9 @@ var GitHubSigninProvider = class {
70
392
  const user = await this.#api(token, "/user");
71
393
  if (typeof user?.id !== "number") throw new SigninError("invalid_response", `${label} did not say who signed in`);
72
394
  const organization = this.config.organization;
73
- if (organization !== null && !await this.#isMember(token, organization)) throw new SigninError("not_in_organization", `only members of the ${organization} organization may sign in with ${label}`);
395
+ const standing = organization === null ? "member" : await this.#membership(token, organization);
396
+ if (standing === "unseen") throw new SigninError("not_in_organization", `${label} would not show ${organization}'s members to coffre: a GitHub App needs the Members organization permission, read, and to be installed on ${organization}; an OAuth app, the organization's approval`);
397
+ if (standing === "outside") throw new SigninError("not_in_organization", `only members of the ${organization} organization may sign in with ${label}`);
74
398
  const listed = await this.#api(token, "/user/emails");
75
399
  const emails = (Array.isArray(listed) ? listed : []).filter((entry) => entry.verified && typeof entry.email === "string").sort((a, b) => Number(b.primary) - Number(a.primary)).map((entry) => entry.email.trim().toLowerCase());
76
400
  return {
@@ -101,12 +425,20 @@ var GitHubSigninProvider = class {
101
425
  if (typeof body.access_token !== "string" || body.access_token === "") throw new SigninError("invalid_response", `${label} returned no access token`);
102
426
  return body.access_token;
103
427
  }
104
- async #isMember(token, organization) {
428
+ /**
429
+ * Whether the person is an active member: `outside` when GitHub says they
430
+ * are not (404), `unseen` when it will not say (403), which a GitHub App's
431
+ * user token gets without the Members permission or an installation on
432
+ * the organization, and an OAuth app's where the organization restricts
433
+ * it. Either way, no sign-in.
434
+ */
435
+ async #membership(token, organization) {
105
436
  const response = await this.#send(`${this.config.apiUrl}/user/memberships/orgs/${encodeURIComponent(organization)}`, { headers: apiHeaders(token) });
106
- if (response.status === 404 || response.status === 403) return false;
437
+ if (response.status === 404) return "outside";
438
+ if (response.status === 403) return "unseen";
107
439
  const body = await readJson(response);
108
440
  if (!response.ok || body === null) throw new SigninError("invalid_response", `${this.config.label} answered the membership check with ${response.status}`);
109
- return body.state === "active";
441
+ return body.state === "active" ? "member" : "outside";
110
442
  }
111
443
  async #api(token, path) {
112
444
  const response = await this.#send(`${this.config.apiUrl}${path}`, { headers: apiHeaders(token) });
@@ -389,19 +721,59 @@ function microsoft(options) {
389
721
  brand: "microsoft"
390
722
  });
391
723
  }
392
- /** GitHub accounts; with `organization`, only that organization's members. */
724
+ /**
725
+ * GitHub accounts; with `organization`, only that organization's members.
726
+ * Through an OAuth app, or a GitHub App's OAuth client, by its credentials;
727
+ * or the deployment's GitHub App, `github({ app })`, the same value its
728
+ * `workloads.github` lists repositories with:
729
+ *
730
+ * const app = githubApp({ appId, privateKey, clientId, clientSecret });
731
+ * signin({ providers: [github({ app, organization: 'acme' })], workloads: { limits, github: app } })
732
+ */
393
733
  function github(options) {
394
734
  const id = checkId(options.id ?? "github");
735
+ const how = (() => {
736
+ if (!("app" in options)) return options;
737
+ const client = options.app instanceof GitHubApp ? options.app.signinClient() : null;
738
+ if (client === null) throw new Error(`sign-in provider ${id} signs in with the GitHub App's OAuth client: githubApp({ appId, privateKey, clientId, clientSecret })`);
739
+ return {
740
+ ...client,
741
+ webUrl: options.app.webUrl,
742
+ apiUrl: options.app.apiUrl
743
+ };
744
+ })();
395
745
  return new GitHubSigninProvider({
396
746
  id,
397
747
  label: options.label ?? "GitHub",
398
748
  brand: "github",
399
- ...credentials(id, options),
400
- webUrl: baseUrl(options.webUrl ?? "https://github.com", `sign-in provider ${id} web URL`),
401
- apiUrl: baseUrl(options.apiUrl ?? "https://api.github.com", `sign-in provider ${id} API URL`),
749
+ ...credentials(id, how),
750
+ webUrl: baseUrl(how.webUrl ?? "https://github.com", `sign-in provider ${id} web URL`),
751
+ apiUrl: baseUrl(how.apiUrl ?? "https://api.github.com", `sign-in provider ${id} API URL`),
402
752
  organization: options.organization?.trim() || null
403
753
  });
404
754
  }
755
+ /**
756
+ * The deployment's GitHub App, checked where it is written: its numeric ID
757
+ * and private key, and its OAuth client when it signs people in too. One
758
+ * value, passed to each side that uses it: `github({ app })` and
759
+ * `workloads.github` (`../github-app.ts`).
760
+ */
761
+ function githubApp(options) {
762
+ const appId = String(options.appId ?? "").trim();
763
+ if (!/^[1-9][0-9]{0,19}$/.test(appId)) throw new Error("githubApp(…).appId must be the app's numeric ID (its settings page, \"App ID\"), not its client ID");
764
+ const [clientId, clientSecret] = [options.clientId?.trim() || null, options.clientSecret || null];
765
+ if (clientId === null !== (clientSecret === null)) throw new Error("githubApp(…) takes its OAuth client whole, clientId and clientSecret, or neither");
766
+ return new GitHubApp({
767
+ appId,
768
+ pkcs8: pkcs8Of(options.privateKey),
769
+ webUrl: baseUrl(options.webUrl ?? "https://github.com", "githubApp(…).webUrl"),
770
+ apiUrl: baseUrl(options.apiUrl ?? "https://api.github.com", "githubApp(…).apiUrl"),
771
+ ...clientId === null || clientSecret === null ? {} : { client: {
772
+ clientId,
773
+ clientSecret
774
+ } }
775
+ });
776
+ }
405
777
  function isLoopback(url) {
406
778
  return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
407
779
  }
@@ -473,9 +845,13 @@ function workloadsConfig(options, publicUrl) {
473
845
  if (!limiter(options.limits?.perSource) || !limiter(options.limits?.total)) throw new Error("workloads need their limits, per source and in total: on Workers, two rate-limiting bindings; on Node, processLimits()");
474
846
  const loopback = options.allowLoopbackIssuersForDevelopment === true;
475
847
  if (loopback && !isLoopback(new URL(publicUrl))) throw new Error(`workloads.allowLoopbackIssuersForDevelopment is for an instance on loopback, and ${publicUrl} is not one: turn it off`);
848
+ const app = options.github;
849
+ if (app !== void 0 && !(app instanceof GitHubApp)) throw new Error("workloads.github is the deployment's GitHub App: githubApp({ appId, privateKey })");
850
+ if (app !== void 0 && app.apiUrl !== "https://api.github.com" && !isLoopback(new URL(app.apiUrl))) throw new Error("workloads.github is for github.com: GitHub Enterprise Server is not supported yet; sign in with its app, and leave workloads.github out");
476
851
  return {
477
852
  allowLoopback: loopback,
478
- limits: options.limits
853
+ limits: options.limits,
854
+ github: app ?? null
479
855
  };
480
856
  }
481
857
  /** A deployment's own provider is checked like coffre's: it is only typed, not trusted. */
@@ -830,4 +1206,4 @@ function refusalOf(error) {
830
1206
  return new WorkloadTokenRefused("signature", "the token does not verify");
831
1207
  }
832
1208
  //#endregion
833
- export { ACCESS_JWT_HEADER, AccessIdentityVerifier, BindingInvalid, CLOCK_TOLERANCE_SECONDS, EVENT_EXPOSURE, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, GitHubSigninProvider, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, MAX_TOKEN_AGE_SECONDS, MAX_TOKEN_BYTES, OidcSigninProvider, REQUIRED_CLAIMS, SigninError, WORKLOAD_ALGORITHMS, WORKLOAD_PROFILES, WorkloadTokenRefused, canonicalClaims, checkBinding, checkFetchUrl, claimValues, cloudflareAccess, decodeWorkloadToken, defaultIssuer, defineSignin, deriveKey, differingClaims, fullRef, generateToken, github, githubEventsAt, githubReusable, githubWorkflow, gitlabProject, google, hashToken, isCoffreToken, issuedRefusal, microsoft, oidc, publicOrigin, seal, signin, toPrincipal, tokenHint, tokenKind, unseal, verifyWorkloadToken, workflowRef };
1209
+ export { ACCESS_JWT_HEADER, AccessIdentityVerifier, BindingInvalid, CLOCK_TOLERANCE_SECONDS, EVENT_EXPOSURE, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, GitHubApp, GitHubAppUnavailable, GitHubSigninProvider, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, MAX_LISTING_REQUESTS, MAX_TOKEN_AGE_SECONDS, MAX_TOKEN_BYTES, OidcSigninProvider, REQUIRED_CLAIMS, SigninError, TOKEN_MARGIN_MS, WORKLOAD_ALGORITHMS, WORKLOAD_PROFILES, WorkloadTokenRefused, canonicalClaims, checkBinding, checkFetchUrl, claimValues, cloudflareAccess, decodeWorkloadToken, defaultIssuer, defineSignin, deriveKey, differingClaims, fullRef, generateToken, github, githubApp, githubEventsAt, githubReusable, githubWorkflow, gitlabProject, google, hashToken, isCoffreToken, issuedRefusal, microsoft, oidc, pkcs8Of, publicOrigin, seal, signin, toPrincipal, tokenHint, tokenKind, unseal, verifyWorkloadToken, workflowRef };
package/dist/schemas.d.ts CHANGED
@@ -29,11 +29,11 @@ export declare const principalType: z.ZodEnum<{
29
29
  }>;
30
30
  export declare const principalId: z.ZodString;
31
31
  export declare const instanceRole: z.ZodEnum<{
32
- member: "member";
33
- auditor: "auditor";
34
32
  developer: "developer";
35
- admin: "admin";
33
+ auditor: "auditor";
36
34
  owner: "owner";
35
+ member: "member";
36
+ admin: "admin";
37
37
  }>;
38
38
  /** Which projects or environments a scope takes in, by slug: `all`, `{ only: [...] }` or `{ except: [...] }`. */
39
39
  export declare const scopeFilter: z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodObject<{
package/dist/schemas.js CHANGED
@@ -1,4 +1,4 @@
1
- import { r as INSTANCE_ROLE_NAMES } from "./access-pnJ-u4x2.js";
1
+ import { r as INSTANCE_ROLE_NAMES } from "./access-DvwZzqRR.js";
2
2
  import { PROJECT_PAGES } from "./pages.js";
3
3
  import { z } from "zod";
4
4
  //#region src/schemas.ts
package/dist/vault.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as Role, b as ServiceSetting, c as InstanceRole, i as GrantPlace, t as CredentialScope, v as Scope } from "./access-CwsFLfTz.js";
1
+ import { _ as Role, b as ServiceSetting, c as InstanceRole, i as GrantPlace, t as CredentialScope, v as Scope } from "./access-BpcBLDW9.js";
2
2
  //#region src/access-fault.d.ts
3
3
  /**
4
4
  * Why the vault's members and grants do not follow from its log, as facts
@@ -138,27 +138,33 @@ export interface Vault {
138
138
  seqs: number[];
139
139
  }>>;
140
140
  /**
141
- * What one principal holds right now: the app asks once per request. A
142
- * decision, its rows checked: lists of who holds what read the rows
143
- * themselves, and need not ask.
141
+ * What one principal holds right now: the app asks once per request,
142
+ * and at sign-in for each verified email. A decision, its rows checked:
143
+ * lists of who holds what read the rows themselves, and need not ask.
144
+ * A person the vault's `owners` names who is no Owner of all of it
145
+ * becomes one here, logged (`reason: 'owners'`).
144
146
  */
145
147
  access(principal: string): Promise<Access>;
146
- /** Set roles at several places for one principal: all of it, or none. */
148
+ /**
149
+ * Set roles at several places for one principal: all of it, or none.
150
+ * With `admit`, a service account that is not a member is admitted
151
+ * first, in the same decision, as `admit` would: `admitted` says so.
152
+ */
147
153
  setAccess(input: SetAccessInput): Promise<Outcome<{
148
154
  changes: AccessChange[];
155
+ admitted?: Admitted;
149
156
  }>>;
150
157
  /**
151
158
  * Add a member, bring back a removed one, or change a person's instance
152
159
  * role and its scope. `generation` is theirs now: sessions and tokens of
153
160
  * an earlier one are dead.
154
161
  */
155
- admit(input: AdmitInput): Promise<Outcome<{
156
- created: boolean;
157
- role: InstanceRole;
158
- scope: Scope;
159
- generation: number;
160
- }>>;
161
- /** Remove a member: revoke every grant and refuse them until admitted again, from `generation` on. */
162
+ admit(input: AdmitInput): Promise<Outcome<Admitted>>;
163
+ /**
164
+ * Remove a member: revoke every grant and refuse them until admitted
165
+ * again, from `generation` on. An Owner, only an Owner of the whole
166
+ * instance removes, and never the last.
167
+ */
162
168
  remove(input: RemoveInput): Promise<Outcome<{
163
169
  revoked: Grant[];
164
170
  generation: number;
@@ -191,12 +197,21 @@ export interface Vault {
191
197
  /**
192
198
  * What the vault's configuration says, which the app shows: the keys its
193
199
  * checkpoints verify under, by the key id each checkpoint names (the one
194
- * it signs with now, and those of the KEKs it replaced), and the root
195
- * admins, as principals.
200
+ * it signs with now, and those of the KEKs it replaced).
196
201
  */
197
202
  about(): Promise<{
198
203
  checkpointKeys: Record<string, CheckpointKey>;
199
- rootAdmins: string[];
204
+ /**
205
+ * The emails of those the deployment names Owners of the whole instance
206
+ * (`owners`), lowercased: the vault makes them Owners, and refuses to
207
+ * demote or remove them. Absent from a vault of 0.5, which names none.
208
+ */
209
+ owners?: string[];
210
+ /**
211
+ * @deprecated Always empty: an app of 0.5, deployed after this vault,
212
+ * lists and checks root admins here until it is. Gone in 0.7.
213
+ */
214
+ rootAdmins: [];
200
215
  }>;
201
216
  /**
202
217
  * What an escrowed vault key is checked against: the vault key it wraps
@@ -248,8 +263,12 @@ export type RefusalCode =
248
263
  'ended' |
249
264
  /** Not allowed to manage access, members or the log. */
250
265
  'not_allowed' |
251
- /** Root admins come from the vault's configuration and cannot be changed. */
252
- 'root_admin' |
266
+ /**
267
+ * The change would leave the instance without an Owner of all of it: the
268
+ * last one is not demoted, narrowed or removed, by anyone, until another
269
+ * is made.
270
+ */
271
+ 'last_owner' |
253
272
  /** Well-formed, but not something the rules allow: a project role on an environment, a service account with an instance role. */
254
273
  'invalid' |
255
274
  /** The vault's own log no longer carries the head the last checkpoint signed, or does not rehash since. */
@@ -407,22 +426,19 @@ export type Access = {
407
426
  /**
408
427
  * `unknown` for someone never admitted, `tampered` for a member whose row
409
428
  * fails the vault's integrity check: refused everything, as `removed` is.
410
- * Root admins are always active.
411
429
  */
412
430
  status: 'active' | 'removed' | 'unknown' | 'tampered';
413
431
  /** Advanced by removal, even if the app cannot commit its credential revocations. */
414
432
  generation: number;
415
- isRootAdmin: boolean;
416
433
  /**
417
434
  * Their instance role and its scope, while active: `member` everywhere
418
- * for service accounts and anyone not active, `owner` everywhere for
419
- * root admins. Scopes name projects by id.
435
+ * for service accounts and anyone not active. Scopes name projects by id.
420
436
  */
421
437
  role: InstanceRole;
422
438
  scope: Scope;
423
439
  /** Live grants only, and none unless active. */
424
440
  grants: Grant[];
425
- /** When the status last changed, and who changed it; null for root admins and strangers. */
441
+ /** When the status last changed, and who changed it; null for strangers. */
426
442
  since: string | null;
427
443
  by: string | null;
428
444
  };
@@ -439,6 +455,12 @@ export type SetAccessInput = Correlation & Through & {
439
455
  actor: string;
440
456
  principal: string;
441
457
  changes: GrantChange[];
458
+ /**
459
+ * Admit the principal, a service account, unless it is a member: a
460
+ * grant refused admits nobody. A vault older than this refuses one that
461
+ * is not a member, `not_a_member`, and admits nobody either.
462
+ */
463
+ admit?: boolean;
442
464
  };
443
465
  export type AdmitInput = Correlation & Through & {
444
466
  actor: string;
@@ -446,11 +468,22 @@ export type AdmitInput = Correlation & Through & {
446
468
  /**
447
469
  * A person's instance role, and where it applies (everywhere, left out).
448
470
  * Left out, an existing member keeps theirs and a new one is a `member`.
449
- * Changing it takes someone who runs the instance, about someone else.
471
+ * Changing it takes someone who runs the instance, about someone else;
472
+ * making someone an Owner, or changing an Owner's, an Owner of the whole
473
+ * instance, who may change their own too. It never leaves the instance
474
+ * without an Owner of all of it (`last_owner`), nor demotes one the
475
+ * deployment names (`owners`; `not_allowed`).
450
476
  */
451
477
  role?: InstanceRole;
452
478
  scope?: Scope;
453
479
  };
480
+ /** A member as `admit` left them; `created` when they were not one before. */
481
+ export type Admitted = {
482
+ created: boolean;
483
+ role: InstanceRole;
484
+ scope: Scope;
485
+ generation: number;
486
+ };
454
487
  export type RemoveInput = Correlation & Through & {
455
488
  actor: string;
456
489
  principal: string;