@goodfellow-cms/github 0.3.0 → 0.5.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/deploy-keys-DAj2oX-N.js +108 -0
- package/dist/index.d.ts +56 -15
- package/dist/index.js +522 -64
- package/dist/testing.d.ts +50 -2
- package/dist/testing.js +231 -11
- package/package.json +5 -3
|
@@ -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,7 +1,5 @@
|
|
|
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";
|
|
1
|
+
import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, OwnerAccess, PagesDomains, RedirectOptions, RepositoryVisibility, RevisionFiles, ScheduledBuilds, SetupHost, SignInError, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, TokenLink, WriteOptions } from "@goodfellow-cms/core";
|
|
2
2
|
//#region src/sign-in.d.ts
|
|
3
|
-
/** The sign-in worker Goodfellow runs for every site. Sites can run their own and name it in `github({ signIn })`. */
|
|
4
|
-
export declare const GOODFELLOW_SIGN_IN = "https://sign-in.goodfellow-cms.workers.dev";
|
|
5
3
|
/** The page's address and how to change it. Replaceable in tests. */
|
|
6
4
|
interface BrowserLocation {
|
|
7
5
|
href(): string;
|
|
@@ -42,6 +40,19 @@ interface GitHubBackendOptions {
|
|
|
42
40
|
name: string;
|
|
43
41
|
email: string;
|
|
44
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
|
+
};
|
|
45
56
|
}
|
|
46
57
|
/**
|
|
47
58
|
* The site's files on GitHub. Reads come from the commit `revision()` last
|
|
@@ -53,8 +64,11 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
53
64
|
readonly pages: PagesDomains;
|
|
54
65
|
readonly editors: SiteEditors;
|
|
55
66
|
readonly ownerAccess: OwnerAccess;
|
|
56
|
-
|
|
67
|
+
/** Absent where the repository holds only content: the update job runs where the code is. */
|
|
68
|
+
readonly updates?: SiteUpdates;
|
|
57
69
|
readonly drafts: SiteDrafts;
|
|
70
|
+
readonly scheduledBuilds: ScheduledBuilds;
|
|
71
|
+
readonly contentOnly: boolean;
|
|
58
72
|
private readonly options;
|
|
59
73
|
private readonly api;
|
|
60
74
|
private readonly repo;
|
|
@@ -68,9 +82,12 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
68
82
|
private readonly treeSizes;
|
|
69
83
|
private readonly blobs;
|
|
70
84
|
constructor(options: GitHubBackendOptions);
|
|
85
|
+
repositoryVisibility(): Promise<RepositoryVisibility>;
|
|
71
86
|
/** Drafts on branches of the repository, with pull requests for review. */
|
|
72
87
|
private draftHost;
|
|
73
88
|
revision(): Promise<string>;
|
|
89
|
+
/** The branch's latest commit, without moving the revision reads are pinned to. */
|
|
90
|
+
private head;
|
|
74
91
|
/** Path → blob id for every file at a commit. Commits never change, so this is cached. */
|
|
75
92
|
private tree;
|
|
76
93
|
private currentTree;
|
|
@@ -78,11 +95,14 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
78
95
|
private readFrom;
|
|
79
96
|
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
80
97
|
readFiles(paths: string[]): Promise<Map<string, string>>;
|
|
98
|
+
private readFilesFrom;
|
|
81
99
|
readBytes(path: string): Promise<Uint8Array | undefined>;
|
|
100
|
+
private bytesFrom;
|
|
82
101
|
list(dir: string): Promise<string[]>;
|
|
102
|
+
filesAt(revision: string): RevisionFiles;
|
|
83
103
|
fileSizes(dir: string): Promise<Map<string, number>>;
|
|
84
104
|
/** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
|
|
85
|
-
useOwnerSignIn(token: TokenSource, end: () => void): void;
|
|
105
|
+
useOwnerSignIn(token: TokenSource, end: () => void, codeToken?: TokenSource): void;
|
|
86
106
|
/** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
|
|
87
107
|
ownerSignInFailed(error: unknown): void;
|
|
88
108
|
write(changes: FileChange[], options: WriteOptions): Promise<{
|
|
@@ -99,12 +119,21 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
99
119
|
readAt(path: string, revision: string): Promise<string | undefined>;
|
|
100
120
|
changedPaths(from: string, to: string): Promise<string[]>;
|
|
101
121
|
/**
|
|
102
|
-
* Reads the deployment GitHub Pages or Vercel created for a commit,
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* if it has ever been deployed
|
|
122
|
+
* Reads the deployment GitHub Pages or Vercel created for a commit, or what
|
|
123
|
+
* Cloudflare Pages reports on it, and for GitHub Actions, the workflow run
|
|
124
|
+
* that builds it: a build that fails never creates a deployment. Before any
|
|
125
|
+
* exists, the site counts as building if it has ever been deployed, or if
|
|
126
|
+
* Cloudflare reported on the commit before.
|
|
106
127
|
*/
|
|
107
128
|
deployStatus(revision: string): Promise<DeployStatus>;
|
|
129
|
+
/**
|
|
130
|
+
* What Cloudflare Pages reports on a commit: a check run of its GitHub App,
|
|
131
|
+
* or a commit status, which fine-grained tokens can read where check runs
|
|
132
|
+
* need a GitHub App's permission. `undefined` if it reported nothing.
|
|
133
|
+
*/
|
|
134
|
+
private cloudflareBuild;
|
|
135
|
+
/** The commit a commit was made on top of, if the token can see it. */
|
|
136
|
+
private parentOf;
|
|
108
137
|
/** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
|
|
109
138
|
private workflowRun;
|
|
110
139
|
/** Which step of a failed run failed. */
|
|
@@ -122,7 +151,7 @@ interface GitHubSetupOptions {
|
|
|
122
151
|
apiUrl?: string;
|
|
123
152
|
/** For GitHub Enterprise. Defaults to `https://github.com`. */
|
|
124
153
|
webUrl?: string;
|
|
125
|
-
/** The sign-in worker, for "Sign in with GitHub".
|
|
154
|
+
/** The address of a sign-in worker, for "Sign in with GitHub". Without one, people sign in with a token. */
|
|
126
155
|
signIn?: string | false;
|
|
127
156
|
/** For tests. */
|
|
128
157
|
fetch?: typeof fetch;
|
|
@@ -136,8 +165,9 @@ interface GitHubSetupOptions {
|
|
|
136
165
|
}
|
|
137
166
|
/**
|
|
138
167
|
* Links to GitHub's token pages with what creating a site needs: creating the
|
|
139
|
-
* repository (administration), writing its files and
|
|
140
|
-
* (contents and workflows),
|
|
168
|
+
* repository and adding deploy keys (administration), writing its files and
|
|
169
|
+
* its build setup (contents and workflows), turning on GitHub Pages (pages),
|
|
170
|
+
* and giving a workflow the key to another of the site's repositories (secrets).
|
|
141
171
|
*/
|
|
142
172
|
export declare function githubSetupTokenLinks(webUrl?: string): {
|
|
143
173
|
fineGrained: string;
|
|
@@ -157,11 +187,22 @@ export interface GitHubOptions {
|
|
|
157
187
|
/** For GitHub Enterprise. Defaults to `https://github.com`. */
|
|
158
188
|
webUrl?: string;
|
|
159
189
|
/**
|
|
160
|
-
* The sign-in worker, which lets editors sign in with GitHub
|
|
161
|
-
* pasting a token.
|
|
162
|
-
* address of your own copy, or `false` for token sign-in only.
|
|
190
|
+
* The address of a sign-in worker, which lets editors sign in with GitHub
|
|
191
|
+
* instead of pasting a token. Without one, editors sign in with a token.
|
|
163
192
|
*/
|
|
164
193
|
signIn?: string | false;
|
|
194
|
+
/**
|
|
195
|
+
* The repository holds only the site's content (`content/` and
|
|
196
|
+
* `public/media/`), and its code is in a repository of its own. The admin
|
|
197
|
+
* panel then leaves adding blocks, which changes code, to a developer.
|
|
198
|
+
*/
|
|
199
|
+
contentOnly?: boolean;
|
|
200
|
+
/**
|
|
201
|
+
* With `contentOnly`, the repository the site's code is kept in, as
|
|
202
|
+
* `owner/name`, where its updates run: the Updates screen follows them.
|
|
203
|
+
* Editors' tokens need to reach it too: a sign-in worker's app installed on it, or tokens made for both.
|
|
204
|
+
*/
|
|
205
|
+
code?: string;
|
|
165
206
|
/** For tests. */
|
|
166
207
|
fetch?: typeof fetch;
|
|
167
208
|
/** For tests. */
|