@goodfellow-cms/github 0.2.0 → 0.4.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.
@@ -0,0 +1,108 @@
1
+ import { encodeBase64Bytes } from "@goodfellow-cms/core";
2
+ import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa.js";
3
+ import { blake2b } from "@noble/hashes/blake2.js";
4
+ //#region \0rolldown/runtime.js
5
+ var __defProp = Object.defineProperty;
6
+ var __exportAll = (all, no_symbols) => {
7
+ let target = {};
8
+ for (var name in all) __defProp(target, name, {
9
+ get: all[name],
10
+ enumerable: true
11
+ });
12
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
13
+ return target;
14
+ };
15
+ //#endregion
16
+ //#region src/deploy-keys.ts
17
+ /**
18
+ * Deploy keys and Actions secrets, for connecting a site's private code and
19
+ * content repositories: each workflow reads the other repository with a
20
+ * deploy key, whose private half is a secret of the repository it runs in.
21
+ * Loaded only by the setup page, when it connects them.
22
+ */
23
+ var deploy_keys_exports = /* @__PURE__ */ __exportAll({
24
+ newDeployKey: () => newDeployKey,
25
+ openSealedSecret: () => openSealedSecret,
26
+ sealSecret: () => sealSecret
27
+ });
28
+ function concat(...parts) {
29
+ const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
30
+ let at = 0;
31
+ for (const part of parts) {
32
+ result.set(part, at);
33
+ at += part.length;
34
+ }
35
+ return result;
36
+ }
37
+ /** SSH's encoding of a number: four bytes, most significant first. */
38
+ function uint32(value) {
39
+ const bytes = /* @__PURE__ */ new Uint8Array(4);
40
+ new DataView(bytes.buffer).setUint32(0, value);
41
+ return bytes;
42
+ }
43
+ /** SSH's encoding of a string of bytes: its length, then the bytes. */
44
+ function sshString(value) {
45
+ const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
46
+ return concat(uint32(bytes.length), bytes);
47
+ }
48
+ /** A new Ed25519 key pair, made with the browser's own Web Crypto, as `ssh-keygen -t ed25519 -N ""` would make it. */
49
+ async function newDeployKey(comment) {
50
+ const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
51
+ const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
52
+ const seed = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey)).slice(-32);
53
+ const publicBlob = concat(sshString("ssh-ed25519"), sshString(publicKey));
54
+ const check = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(4));
55
+ let secret = concat(check, check, sshString("ssh-ed25519"), sshString(publicKey), sshString(concat(seed, publicKey)), sshString(comment));
56
+ const padding = Array.from({ length: (8 - secret.length % 8) % 8 }, (_, index) => index + 1);
57
+ secret = concat(secret, new Uint8Array(padding));
58
+ const body = concat(new TextEncoder().encode("openssh-key-v1\0"), sshString("none"), sshString("none"), sshString(""), uint32(1), sshString(publicBlob), sshString(secret));
59
+ const lines = encodeBase64Bytes(body).match(/.{1,70}/g) ?? [];
60
+ seed.fill(0);
61
+ secret.fill(0);
62
+ return {
63
+ publicKey: `ssh-ed25519 ${encodeBase64Bytes(publicBlob)} ${comment}`,
64
+ privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----\n${lines.join("\n")}\n-----END OPENSSH PRIVATE KEY-----\n`
65
+ };
66
+ }
67
+ const SIGMA = new TextEncoder().encode("expand 32-byte k");
68
+ function words(bytes) {
69
+ return new Uint32Array(bytes.slice().buffer);
70
+ }
71
+ /**
72
+ * Encrypts a value for an Actions secret, as libsodium's `crypto_box_seal`
73
+ * does, which is what GitHub expects: an X25519 key of its own, the Web
74
+ * Crypto key agreement with the repository's public key, and XSalsa20-Poly1305.
75
+ */
76
+ async function sealSecret(value, recipient) {
77
+ const ephemeral = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
78
+ const ephemeralPublic = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
79
+ const recipientKey = await crypto.subtle.importKey("raw", recipient.slice(), { name: "X25519" }, false, []);
80
+ const shared = new Uint8Array(await crypto.subtle.deriveBits({
81
+ name: "X25519",
82
+ public: recipientKey
83
+ }, ephemeral.privateKey, 256));
84
+ const key = /* @__PURE__ */ new Uint32Array(8);
85
+ hsalsa(words(SIGMA), words(shared), /* @__PURE__ */ new Uint32Array(4), key);
86
+ const nonce = blake2b(concat(ephemeralPublic, recipient), { dkLen: 24 });
87
+ const keyBytes = new Uint8Array(key.buffer);
88
+ const sealed = xsalsa20poly1305(keyBytes, nonce).encrypt(new TextEncoder().encode(value));
89
+ shared.fill(0);
90
+ keyBytes.fill(0);
91
+ return concat(ephemeralPublic, sealed);
92
+ }
93
+ /** Opens a sealed value with the recipient's X25519 key pair, as GitHub does with a secret. For the fake. */
94
+ async function openSealedSecret(sealed, recipient) {
95
+ const ephemeralPublic = sealed.slice(0, 32);
96
+ const recipientPublic = new Uint8Array(await crypto.subtle.exportKey("raw", recipient.publicKey));
97
+ const ephemeralKey = await crypto.subtle.importKey("raw", ephemeralPublic, { name: "X25519" }, false, []);
98
+ const shared = new Uint8Array(await crypto.subtle.deriveBits({
99
+ name: "X25519",
100
+ public: ephemeralKey
101
+ }, recipient.privateKey, 256));
102
+ const key = /* @__PURE__ */ new Uint32Array(8);
103
+ hsalsa(words(SIGMA), words(shared), /* @__PURE__ */ new Uint32Array(4), key);
104
+ const nonce = blake2b(concat(ephemeralPublic, recipientPublic), { dkLen: 24 });
105
+ return new TextDecoder().decode(xsalsa20poly1305(new Uint8Array(key.buffer), nonce).decrypt(sealed.slice(32)));
106
+ }
107
+ //#endregion
108
+ export { openSealedSecret as n, deploy_keys_exports as t };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,22 @@
1
- import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, OwnerAccess, PagesDomains, SetupHost, SiteEditors, SiteUpdates, StorageLike, TokenLink, WriteOptions } from "@goodfellow-cms/core";
1
+ import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, OwnerAccess, PagesDomains, RedirectOptions, SetupHost, SignInError, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, TokenLink, WriteOptions } from "@goodfellow-cms/core";
2
+ //#region src/sign-in.d.ts
3
+ /** The page's address and how to change it. Replaceable in tests. */
4
+ interface BrowserLocation {
5
+ href(): string;
6
+ /** Navigates away, for example to GitHub's sign-in page. */
7
+ assign(url: string): void;
8
+ /** Changes the address without reloading, for example to remove the sign-in's details after coming back. */
9
+ replace(url: string): void;
10
+ }
11
+ //#endregion
2
12
  //#region src/api.d.ts
13
+ /** A token that renews itself, such as one from the sign-in worker. `refresh` asks for a new one even if it hasn't expired. */
14
+ type TokenSource = (options?: {
15
+ refresh?: boolean;
16
+ }) => Promise<string>;
3
17
  interface ApiOptions {
4
18
  apiUrl: string;
5
- token: string;
19
+ token: string | TokenSource;
6
20
  fetch: typeof fetch;
7
21
  }
8
22
  //#endregion
@@ -15,6 +29,30 @@ interface GitHubBackendOptions {
15
29
  onSignOut: () => void;
16
30
  /** Where to create the owner token, which can change editors and GitHub Pages settings. */
17
31
  ownerTokenLink: TokenLink;
32
+ /** Signs in as an owner through the sign-in worker instead of with a token, where it's available. */
33
+ ownerRedirect?: (options?: RedirectOptions) => Promise<void>;
34
+ /**
35
+ * Who commits name as their author. Set when the token is an app's
36
+ * installation token from the sign-in worker, whose commits would otherwise
37
+ * name the app. Commits are then made with Git's data API, which can name them.
38
+ */
39
+ author?: {
40
+ name: string;
41
+ email: string;
42
+ };
43
+ /** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
44
+ revision?: string;
45
+ /** The repository holds only the site's content, with its code in a repository of its own (`GitBackend.contentOnly`). */
46
+ contentOnly?: boolean;
47
+ /**
48
+ * The code repository, for a repository that holds only content, where the
49
+ * site's updates run: `api` reads it, absent where the sign-in can't.
50
+ */
51
+ code?: {
52
+ repo: string;
53
+ webUrl: string;
54
+ api?: ApiOptions;
55
+ };
18
56
  }
19
57
  /**
20
58
  * The site's files on GitHub. Reads come from the commit `revision()` last
@@ -26,26 +64,50 @@ export declare class GitHubBackend implements GitBackend {
26
64
  readonly pages: PagesDomains;
27
65
  readonly editors: SiteEditors;
28
66
  readonly ownerAccess: OwnerAccess;
29
- readonly updates: SiteUpdates;
67
+ /** Absent where the repository holds only content: the update job runs where the code is. */
68
+ readonly updates?: SiteUpdates;
69
+ readonly drafts: SiteDrafts;
70
+ readonly contentOnly: boolean;
71
+ private readonly options;
30
72
  private readonly api;
31
73
  private readonly repo;
32
74
  private readonly branch;
33
75
  private readonly onSignOut;
76
+ private readonly author?;
77
+ private readonly owner;
34
78
  private pinned?;
35
79
  private readonly trees;
80
+ /** Each file's size in bytes at a commit, which GitHub lists with its tree. */
81
+ private readonly treeSizes;
36
82
  private readonly blobs;
37
83
  constructor(options: GitHubBackendOptions);
84
+ /** Drafts on branches of the repository, with pull requests for review. */
85
+ private draftHost;
38
86
  revision(): Promise<string>;
39
87
  /** Path → blob id for every file at a commit. Commits never change, so this is cached. */
40
88
  private tree;
41
89
  private currentTree;
42
90
  read(path: string): Promise<string | undefined>;
43
91
  private readFrom;
92
+ /** Reads many text files in a few GraphQL requests rather than one request each. */
93
+ readFiles(paths: string[]): Promise<Map<string, string>>;
44
94
  readBytes(path: string): Promise<Uint8Array | undefined>;
45
95
  list(dir: string): Promise<string[]>;
46
- write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
96
+ fileSizes(dir: string): Promise<Map<string, number>>;
97
+ /** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
98
+ useOwnerSignIn(token: TokenSource, end: () => void, codeToken?: TokenSource): void;
99
+ /** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
100
+ ownerSignInFailed(error: unknown): void;
101
+ write(changes: FileChange[], options: WriteOptions): Promise<{
47
102
  revision: string;
48
103
  }>;
104
+ /**
105
+ * Saves with Git's data API, naming the editor as the commit's author:
106
+ * GitHub refuses to move the branch unless the commit follows on from it, so
107
+ * nothing is overwritten.
108
+ */
109
+ private writeCommit;
110
+ private writeGraphQL;
49
111
  history(path: string, { page, perPage }?: HistoryOptions): Promise<FileVersion[]>;
50
112
  readAt(path: string, revision: string): Promise<string | undefined>;
51
113
  changedPaths(from: string, to: string): Promise<string[]>;
@@ -73,13 +135,23 @@ interface GitHubSetupOptions {
73
135
  apiUrl?: string;
74
136
  /** For GitHub Enterprise. Defaults to `https://github.com`. */
75
137
  webUrl?: string;
138
+ /** The address of a sign-in worker, for "Sign in with GitHub". Without one, people sign in with a token. */
139
+ signIn?: string | false;
76
140
  /** For tests. */
77
141
  fetch?: typeof fetch;
142
+ /** For tests. */
143
+ storage?: {
144
+ local?: StorageLike;
145
+ session?: StorageLike;
146
+ };
147
+ /** For tests. */
148
+ location?: BrowserLocation;
78
149
  }
79
150
  /**
80
151
  * Links to GitHub's token pages with what creating a site needs: creating the
81
- * repository (administration), writing its files and its build setup
82
- * (contents and workflows), and turning on GitHub Pages (pages).
152
+ * repository and adding deploy keys (administration), writing its files and
153
+ * its build setup (contents and workflows), turning on GitHub Pages (pages),
154
+ * and giving a workflow the key to another of the site's repositories (secrets).
83
155
  */
84
156
  export declare function githubSetupTokenLinks(webUrl?: string): {
85
157
  fineGrained: string;
@@ -98,6 +170,23 @@ export interface GitHubOptions {
98
170
  apiUrl?: string;
99
171
  /** For GitHub Enterprise. Defaults to `https://github.com`. */
100
172
  webUrl?: string;
173
+ /**
174
+ * The address of a sign-in worker, which lets editors sign in with GitHub
175
+ * instead of pasting a token. Without one, editors sign in with a token.
176
+ */
177
+ signIn?: string | false;
178
+ /**
179
+ * The repository holds only the site's content (`content/` and
180
+ * `public/media/`), and its code is in a repository of its own. The admin
181
+ * panel then leaves adding blocks, which changes code, to a developer.
182
+ */
183
+ contentOnly?: boolean;
184
+ /**
185
+ * With `contentOnly`, the repository the site's code is kept in, as
186
+ * `owner/name`, where its updates run: the Updates screen follows them.
187
+ * Editors' tokens need to reach it too: a sign-in worker's app installed on it, or tokens made for both.
188
+ */
189
+ code?: string;
101
190
  /** For tests. */
102
191
  fetch?: typeof fetch;
103
192
  /** For tests. */
@@ -105,6 +194,8 @@ export interface GitHubOptions {
105
194
  local?: StorageLike;
106
195
  session?: StorageLike;
107
196
  };
197
+ /** For tests. */
198
+ location?: BrowserLocation;
108
199
  }
109
200
  /**
110
201
  * Links to GitHub's token page with everything filled in except the
@@ -118,4 +209,4 @@ export declare function githubTokenLinks(repo: string, webUrl?: string): {
118
209
  /** Sign-in and storage for a site whose repository is on GitHub. */
119
210
  export declare function github(options: GitHubOptions): GitHost;
120
211
  //#endregion
121
- export type { GitHubSetupOptions };
212
+ export type { BrowserLocation, GitHubSetupOptions };