@magland/mochi 0.3.1 → 0.4.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.
Files changed (83) hide show
  1. package/README.md +7 -6
  2. package/dist/agefile.js +47 -0
  3. package/dist/agescript.js +343 -0
  4. package/dist/api/admin.js +66 -28
  5. package/dist/api/auth.js +3 -2
  6. package/dist/api/backup.js +227 -192
  7. package/dist/api/contents.js +46 -16
  8. package/dist/api/repos.js +60 -4
  9. package/dist/api/write.js +138 -8
  10. package/dist/api.js +34 -1
  11. package/dist/browse.js +171 -33
  12. package/dist/build-info.json +2 -2
  13. package/dist/ci/api.js +138 -4
  14. package/dist/ci/artifacts.js +82 -2
  15. package/dist/ci/engine.js +30 -17
  16. package/dist/ci/expr.js +14 -0
  17. package/dist/ci/runners.js +60 -1
  18. package/dist/ci/runs.js +21 -3
  19. package/dist/ci/trigger.js +6 -4
  20. package/dist/ci/views.js +24 -3
  21. package/dist/ci/web.js +48 -0
  22. package/dist/cli/admin-cmd.js +40 -3
  23. package/dist/cli/backup-cmd.js +166 -120
  24. package/dist/cli/gitrun.js +50 -0
  25. package/dist/cli/pr-cmd.js +155 -0
  26. package/dist/cli/repo-cmd.js +134 -6
  27. package/dist/cli/sync-cmd.js +128 -0
  28. package/dist/cli/target.js +7 -3
  29. package/dist/config.js +10 -0
  30. package/dist/credentials.js +9 -8
  31. package/dist/deploy-cli.js +214 -109
  32. package/dist/deploy-runner-cli.js +125 -49
  33. package/dist/domains.js +259 -0
  34. package/dist/egress.js +23 -6
  35. package/dist/forms.js +703 -32
  36. package/dist/git.js +124 -13
  37. package/dist/githttp.js +80 -23
  38. package/dist/githubauth.js +185 -0
  39. package/dist/icons.js +14 -0
  40. package/dist/import-cli.js +40 -25
  41. package/dist/index.js +110 -4
  42. package/dist/issueweb.js +10 -6
  43. package/dist/lfs.js +9 -2
  44. package/dist/limit.js +2 -2
  45. package/dist/logincodes.js +31 -0
  46. package/dist/maintenance.js +297 -0
  47. package/dist/markdown.js +193 -3
  48. package/dist/mdedit.js +29 -0
  49. package/dist/migrate.js +48 -0
  50. package/dist/naming.js +25 -0
  51. package/dist/onetime.js +112 -0
  52. package/dist/ops.js +293 -16
  53. package/dist/pagescript.js +355 -24
  54. package/dist/perms.js +1 -0
  55. package/dist/profile.js +2 -0
  56. package/dist/pulls.js +21 -1
  57. package/dist/pullweb.js +25 -6
  58. package/dist/releases.js +10 -1
  59. package/dist/runner/actions.js +12 -1
  60. package/dist/runner/client.js +40 -2
  61. package/dist/runner/docker.js +40 -28
  62. package/dist/runner/download.js +77 -0
  63. package/dist/runner/externals.js +12 -1
  64. package/dist/runner/job.js +26 -11
  65. package/dist/runner/steps.js +42 -7
  66. package/dist/runner-cli.js +89 -0
  67. package/dist/scan.js +58 -2
  68. package/dist/server.js +107 -26
  69. package/dist/session.js +82 -40
  70. package/dist/site.js +110 -27
  71. package/dist/sitepublish.js +177 -0
  72. package/dist/sitesettings.js +420 -0
  73. package/dist/siteshost.js +116 -28
  74. package/dist/source.js +20 -0
  75. package/dist/style.js +162 -3
  76. package/dist/topics.js +165 -0
  77. package/dist/vault.js +399 -6
  78. package/dist/vendor-age.js +15 -0
  79. package/dist/views.js +242 -57
  80. package/dist/web.js +11 -1
  81. package/dist/webauthn.js +347 -0
  82. package/dist/webops.js +1263 -33
  83. package/package.json +4 -4
package/dist/forms.js CHANGED
@@ -2,31 +2,45 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.flashBanner = flashBanner;
4
4
  exports.loginPage = loginPage;
5
+ exports.loginLinkPage = loginLinkPage;
6
+ exports.loginLinkConfirmPage = loginLinkConfirmPage;
7
+ exports.accountPage = accountPage;
8
+ exports.accountLinkPage = accountLinkPage;
5
9
  exports.newRepoPage = newRepoPage;
6
10
  exports.forkPage = forkPage;
7
11
  exports.importPage = importPage;
12
+ exports.syncPage = syncPage;
8
13
  exports.newCollectionPage = newCollectionPage;
9
14
  exports.collectionSettingsPage = collectionSettingsPage;
10
15
  exports.editFilePage = editFilePage;
16
+ exports.editAgeFilePage = editAgeFilePage;
11
17
  exports.newFilePage = newFilePage;
12
18
  exports.uploadPage = uploadPage;
13
19
  exports.deleteFilePage = deleteFilePage;
14
20
  exports.conflictPage = conflictPage;
21
+ exports.isSettingsSection = isSettingsSection;
15
22
  exports.settingsPage = settingsPage;
23
+ exports.profileSettingsPage = profileSettingsPage;
16
24
  exports.adminUsersPage = adminUsersPage;
17
25
  exports.adminUserPage = adminUserPage;
18
26
  exports.adminShell = adminShell;
19
27
  exports.adminIndexPage = adminIndexPage;
20
28
  exports.egressPage = egressPage;
21
29
  exports.appearancePage = appearancePage;
30
+ exports.isVaultSettingsSection = isVaultSettingsSection;
31
+ exports.vaultSettingsPage = vaultSettingsPage;
32
+ exports.adminGithubPage = adminGithubPage;
22
33
  exports.tokenPage = tokenPage;
23
34
  exports.opErrorPage = opErrorPage;
24
35
  const avatar_1 = require("./avatar");
25
36
  const html_1 = require("./html");
26
37
  const icons_1 = require("./icons");
38
+ const markdown_1 = require("./markdown");
39
+ const mdedit_1 = require("./mdedit");
27
40
  const logo_1 = require("./logo");
28
41
  const render_1 = require("./render");
29
42
  const perms_1 = require("./perms");
43
+ const siteshost_1 = require("./siteshost");
30
44
  const vault_1 = require("./vault");
31
45
  const views_1 = require("./views");
32
46
  // Form pages for the UI operations. Every mutating form embeds the session's
@@ -38,9 +52,12 @@ function errorBanner(error) {
38
52
  function flashBanner(msg) {
39
53
  return msg ? (0, html_1.html) `<div class="flash">${msg}</div>` : '';
40
54
  }
41
- function loginPage(next, error) {
55
+ function loginPage(next, error, github = false) {
42
56
  // A narrow card under the mark, centred on the page: signing in is the one
43
- // thing this page is for, so nothing else is on it.
57
+ // thing this page is for, so nothing else is on it. The passkey button is
58
+ // hidden until the page script finds WebAuthn support, so a browser without
59
+ // it sees the form it can use and nothing it cannot; the GitHub button is
60
+ // decided by the server, since whether it can work is the vault's own state.
44
61
  const content = (0, html_1.html) `<div class="signin">
45
62
  <div class="signin-mark">${(0, html_1.raw)(logo_1.MARK)}</div>
46
63
  <h1>Sign in to Mochi Forge</h1>
@@ -52,11 +69,158 @@ ${errorBanner(error)}
52
69
  <div class="field"><label for="token">Token</label><input type="password" id="token" name="token" autocomplete="current-password" required></div>
53
70
  <button type="submit" class="btn btn-primary">Sign in</button>
54
71
  </form>
72
+ <div class="signin-alt" data-passkey-login hidden>
73
+ <button type="button" class="btn" data-passkey-login-btn data-next="${next}">${(0, icons_1.icon)('lock')}<span>Sign in with a passkey</span></button>
74
+ <div class="form-error" data-passkey-error hidden></div>
55
75
  </div>
56
- <p class="muted small signin-note">A token is what git uses for pushing. Tokens are minted by an administrator; there are no passwords.</p>
76
+ ${github
77
+ ? (0, html_1.html) `<div class="signin-alt"><a class="btn" href="/login/github?next=${encodeURIComponent(next)}">${(0, icons_1.icon)('person')}<span>Sign in with GitHub</span></a></div>`
78
+ : ''}
79
+ </div>
80
+ <p class="muted small signin-note">A token is what git uses for pushing. Tokens are minted by an administrator; there are no passwords.
81
+ Passkeys are added from your account page, once you are signed in.</p>
82
+ <p class="muted small signin-note">Already signed in on another device? <a href="/login/link?next=${encodeURIComponent(next)}">Enter a code from it</a>.</p>
57
83
  </div>`;
58
84
  return (0, views_1.layout)('Sign in', content, { path: '/login' });
59
85
  }
86
+ /** The page a handoff code is typed into: the other half of accountLinkPage. */
87
+ function loginLinkPage(next, error) {
88
+ const content = (0, html_1.html) `<div class="signin">
89
+ <div class="signin-mark">${(0, html_1.raw)(logo_1.MARK)}</div>
90
+ <h1>Sign in with a code</h1>
91
+ ${errorBanner(error)}
92
+ <div class="form-box">
93
+ <form method="post" action="/login/link">
94
+ <input type="hidden" name="next" value="${next}">
95
+ <div class="field"><label for="code">Code</label><input type="text" id="code" name="code" class="mono" autocomplete="off" spellcheck="false" placeholder="ABCD-EFGH" autofocus required></div>
96
+ <button type="submit" class="btn btn-primary">Sign in</button>
97
+ </form>
98
+ </div>
99
+ <p class="muted small signin-note">On a device where you are already signed in, open your account page and choose
100
+ &ldquo;Sign in on another device&rdquo;. The code it shows works once and expires after a few minutes.</p>
101
+ </div>`;
102
+ return (0, views_1.layout)('Sign in with a code', content, { path: '/login' });
103
+ }
104
+ /**
105
+ * The page a `mochi web` link lands on. It names the account and asks for a
106
+ * click rather than signing the browser in on arrival: a GET must not change
107
+ * who the browser is, or a link someone was tricked into opening would sign
108
+ * them in as somebody else without a word.
109
+ */
110
+ function loginLinkConfirmPage(username, code, next) {
111
+ const content = (0, html_1.html) `<div class="signin">
112
+ <div class="signin-mark">${(0, html_1.raw)(logo_1.MARK)}</div>
113
+ <h1>Sign in to Mochi Forge</h1>
114
+ <div class="form-box">
115
+ <p>This link signs this browser in as <b>${username}</b>.</p>
116
+ <form method="post" action="/login/code">
117
+ <input type="hidden" name="code" value="${code}">
118
+ <input type="hidden" name="next" value="${next}">
119
+ <button type="submit" class="btn btn-primary">Continue as ${username}</button>
120
+ </form>
121
+ </div>
122
+ <p class="muted small signin-note">Not you, or not something you asked for? Close this page; the link works once and expires by itself.</p>
123
+ </div>`;
124
+ return (0, views_1.layout)('Sign in', content, { path: '/login' });
125
+ }
126
+ /**
127
+ * The signed-in user's own page: their passkeys and the way to another
128
+ * device. Tokens are deliberately not here; they are minted and revoked on
129
+ * the admin pages, and this page holds only what a user manages for
130
+ * themselves.
131
+ */
132
+ function accountPage(viewer, opts = { restricted: false }) {
133
+ const name = viewer.auth.username;
134
+ const user = viewer.auth.user;
135
+ const passkeys = user.passkeys ?? [];
136
+ const rows = passkeys.map((p) => {
137
+ const current = viewer.auth.token.hash === (0, vault_1.passkeyBinding)(p.id);
138
+ return (0, html_1.html) `<tr><td>${p.name ?? 'Passkey'}${current ? (0, html_1.raw)(' <span class="counter">this session</span>') : ''}</td><td class="muted small">${p.created ? (0, render_1.timeTag)(p.created, '') : ''}</td><td class="mono small muted">${p.id.slice(0, 8)}&hellip;</td><td class="right">
139
+ <form method="post" action="/account/passkeys/${encodeURIComponent(p.id)}/delete" class="inline-form">
140
+ ${(0, views_1.csrfField)(viewer)}
141
+ <button type="submit" class="btn btn-danger-outline">Remove</button>
142
+ </form></td></tr>`;
143
+ });
144
+ const list = passkeys.length
145
+ ? (0, html_1.html) `<table class="listing"><tbody><tr><th>Passkey</th><th>Added</th><th>Id</th><th class="right"></th></tr>${rows}</tbody></table>
146
+ <p class="muted small">Removing a passkey is immediate: a session signed in with it is signed out on its next page load.</p>`
147
+ : (0, html_1.html) `<p class="muted">No passkeys yet.</p>`;
148
+ const add = opts.restricted
149
+ ? (0, html_1.html) `<p class="muted small">This session was signed in with a restricted token, which may not add passkeys:
150
+ a passkey signs in with your full account, so adding one takes a session that already has it.</p>`
151
+ : (0, html_1.html) `<div class="inline-form" data-passkey-form>
152
+ ${(0, views_1.csrfField)(viewer)}
153
+ <label for="pk-name">Name</label><input type="text" id="pk-name" name="name" placeholder="e.g. laptop" autocomplete="off">
154
+ <button type="button" class="btn" data-passkey-add>${(0, icons_1.icon)('plus')}<span>Add a passkey</span></button>
155
+ </div>
156
+ <div class="form-error" data-passkey-error hidden></div>
157
+ <p class="muted small" data-passkey-unsupported hidden>This browser does not offer passkeys here. Passkeys need a browser
158
+ with WebAuthn, over https (or on localhost).</p>
159
+ <p class="muted small">A passkey signs you in to this vault's web pages with your screen lock or security key, instead of
160
+ the token. It is kept by your browser or device; the vault stores only its public half. git keeps using tokens.</p>`;
161
+ const passkeyBox = (0, html_1.html) `<div class="form-box">
162
+ <h2>Passkeys</h2>
163
+ ${list}
164
+ ${add}
165
+ </div>`;
166
+ const gh = opts.github;
167
+ // Shown when the vault offers GitHub sign-in, and also when it no longer
168
+ // does but a link remains: a stored credential should never be invisible to
169
+ // the person it belongs to.
170
+ const githubBox = !gh || (!gh.enabled && !gh.account)
171
+ ? ''
172
+ : (0, html_1.html) `<div class="form-box">
173
+ <h2>GitHub</h2>
174
+ ${gh.account
175
+ ? (0, html_1.html) `<p>Linked to <a href="https://github.com/${gh.account.login}"><b>${gh.account.login}</b></a> <span class="mono small muted">id ${String(gh.account.id)}</span>${gh.account.linked ? (0, html_1.html) `, since ${(0, render_1.timeTag)(gh.account.linked, '')}` : ''}.</p>
176
+ <p class="muted small">&ldquo;Sign in with GitHub&rdquo; on the sign-in page signs in as <b>${name}</b>. Unlinking is
177
+ immediate: a session signed in with GitHub is signed out on its next page load. git keeps using tokens either way.</p>
178
+ <form method="post" action="/account/github/unlink" class="inline-form">
179
+ ${(0, views_1.csrfField)(viewer)}
180
+ <button type="submit" class="btn btn-danger-outline">Unlink</button>
181
+ </form>`
182
+ : opts.restricted
183
+ ? (0, html_1.html) `<p class="muted small">This session was signed in with a restricted token, which may not link a GitHub
184
+ account: signing in with GitHub carries your full standing, so linking takes a session that already has it.</p>`
185
+ : (0, html_1.html) `<form method="post" action="/account/github" class="inline-form">
186
+ ${(0, views_1.csrfField)(viewer)}
187
+ <button type="submit" class="btn">${(0, icons_1.icon)('link')}<span>Link a GitHub account</span></button>
188
+ </form>
189
+ <p class="muted small">Linking sends you to GitHub to authorize once; nothing of GitHub's is stored here beyond the
190
+ account's id and name. Afterwards &ldquo;Sign in with GitHub&rdquo; on the sign-in page signs you in as <b>${name}</b>.
191
+ git keeps using tokens.</p>`}
192
+ </div>`;
193
+ const handoff = (0, html_1.html) `<div class="form-box">
194
+ <h2>Sign in on another device</h2>
195
+ <form method="post" action="/account/link">
196
+ ${(0, views_1.csrfField)(viewer)}
197
+ <button type="submit" class="btn">${(0, icons_1.icon)('link')}<span>Show a sign-in code</span></button>
198
+ </form>
199
+ <p class="muted small">Shows a short code. On the other device, open the sign-in page, choose &ldquo;Enter a code&rdquo;,
200
+ and type it. The code works once, expires after five minutes, and signs that device in as you.</p>
201
+ </div>`;
202
+ const content = (0, html_1.html) `<div class="page-head"><div class="with-avatar">${(0, avatar_1.avatar)(name, 32)}<h1>${name}</h1></div></div>
203
+ ${flashBanner(opts.msg)}
204
+ ${errorBanner(opts.error)}
205
+ <p class="muted">${user.siteAdmin ? 'Site admin' : (0, html_1.html) `Owns the collection <span class="mono">${name}</span> by name`}</p>
206
+ ${passkeyBox}
207
+ ${githubBox}
208
+ ${handoff}`;
209
+ return (0, views_1.layout)('Your account', content, { viewer, path: '/account' });
210
+ }
211
+ /** The code accountPage's handoff form minted, shown once. */
212
+ function accountLinkPage(viewer, code, minutes) {
213
+ const content = (0, html_1.html) `<div class="form-box">
214
+ <h1>Sign in on another device</h1>
215
+ <p>On the other device, open this vault's sign-in page, choose <b>Enter a code from a signed-in device</b>, and type:</p>
216
+ <div class="handoff-code mono">${code}</div>
217
+ <p class="muted small">The code signs that device in as <b>${viewer.auth.username}</b>. It works once and expires in
218
+ ${minutes} minutes; showing it again mints a fresh one. The new session is tied to the same credential as this one, so
219
+ revoking that credential signs out both.</p>
220
+ <p><a href="/account">&larr; Your account</a></p>
221
+ </div>`;
222
+ return (0, views_1.layout)('Sign in on another device', content, { viewer, path: '/account' });
223
+ }
60
224
  function newRepoPage(viewer, collectionNames, preset, error) {
61
225
  const datalist = collectionNames.map((o) => (0, html_1.html) `<option value="${o}">`);
62
226
  const content = (0, html_1.html) `<div class="form-box wide">
@@ -111,13 +275,15 @@ ${(0, views_1.csrfField)(viewer)}
111
275
  return (0, views_1.layout)(`Fork ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, `${base}/fork`));
112
276
  }
113
277
  /**
114
- * The import page. Importing has always run on the reader's machine rather than
115
- * on the server, and what this page does is hand them the command
116
- * that does it. Earlier it asked for the source in a form and wrote a shell
278
+ * The import-or-fork page. Both operations run on the reader's machine rather
279
+ * than on the server, and what this page does is hand them the command that
280
+ * does it. Earlier it asked for the source in a form and wrote a shell
117
281
  * one-liner from the answers, which put a form in front of an operation the
118
- * page cannot perform; now `mochi import` performs it, so the page only has
119
- * to say what to run, with the collection and the vault's own URL filled in.
120
- * The git commands remain below for a machine with no Node on it.
282
+ * page cannot perform; now `mochi fork` and `mochi import` perform it, so the
283
+ * page only has to say what to run, with the collection and the vault's own
284
+ * URL filled in. The two commands do the same copy; the difference is that a
285
+ * fork records where it came from, so the page is organized around that one
286
+ * question. The git commands remain below for a machine with no Node on it.
121
287
  */
122
288
  function importPage(viewer, opts) {
123
289
  const collection = opts.collection ?? 'mycollection';
@@ -127,23 +293,57 @@ function importPage(viewer, opts) {
127
293
  const fallback = opts.gitCommand
128
294
  ? (0, html_1.html) `<hr class="rule">
129
295
  <h2>Without the CLI</h2>
130
- <p class="muted small">The same two steps by hand. Replace the source URL, and the name after the collection if you want one other than the source's.</p>
296
+ <p class="muted small">The same two steps by hand. Replace the source URL, and the name after the collection if you want one other than the source's. This makes an import; to turn it into a fork, record the upstream in the repository's settings afterwards.</p>
131
297
  ${(0, views_1.copyRow)(opts.gitCommand)}`
132
298
  : '';
133
299
  const content = (0, html_1.html) `<div class="form-box wide">
134
- <h1>Import a repository</h1>
135
- <p class="muted">Importing runs on your machine, not on this server: git reads the source with the credentials you already have there and pushes it here, which creates the repository. The <span class="mono">mochi</span> command does both.</p>
300
+ <h1>Import or fork a repository</h1>
301
+ <p class="muted">Both copy a repository from outside this vault into it, and both run on your machine, not on this server: git reads the source with the credentials you already have there and pushes it here, which creates the repository. The <span class="mono">mochi</span> command does both. The difference is memory: a fork records where it came from, an import does not.</p>
136
302
  <hr class="rule">
137
303
  <h2>Once per machine</h2>
138
304
  ${(0, views_1.copyRow)('npm install -g @magland/mochi')}
139
305
  ${(0, views_1.copyRow)(`mochi login ${opts.vaultUrl}`)}
140
- <h2>Then, for each repository</h2>
306
+ <h2>Fork: a copy that remembers its source</h2>
307
+ ${(0, views_1.copyRow)(`mochi fork https://github.com/owner/repo ${collection}`)}
308
+ <p class="muted small">The source URL is recorded as the repository's upstream: the header shows &ldquo;forked from&rdquo;, <span class="mono">mochi sync</span> fast-forwards a branch from it, and <span class="mono">mochi pr export</span> sends a pull request back to it. When the source keeps living where it is, this is usually what you want.</p>
309
+ <h2>Import: a copy that stands on its own</h2>
141
310
  ${(0, views_1.copyRow)(`mochi import https://github.com/owner/repo ${collection}`)}
142
- <p class="muted small">The source may be an https or ssh git URL, or <span class="mono">owner/repo</span> for GitHub. The repository takes its name from the source; write <span class="mono">${collection}/another-name</span> to choose another. Add <span class="mono">--lfs</span> to carry Git LFS objects too. A collection that does not exist yet is created by the push. A public GitHub source also has its description read from GitHub and set here.</p>
311
+ <p class="muted small">Nothing is recorded about the source. For a repository whose home this vault becomes, or for a local directory (<span class="mono">mochi import ~/work/repo ${collection}</span>), which has no URL to record.</p>
312
+ <hr class="rule">
313
+ <p class="muted small">For either command, the source may be an https or ssh git URL, or <span class="mono">owner/repo</span> for GitHub. The repository takes its name from the source; write <span class="mono">${collection}/another-name</span> to choose another. Add <span class="mono">--lfs</span> to carry Git LFS objects too. A collection that does not exist yet is created by the push. A public GitHub source also has its description read from GitHub and set here.</p>
314
+ <p class="muted small">An import becomes a fork after the fact: set the upstream URL in the repository's settings, or run <span class="mono">mochi repo edit ${collection}/name --upstream &lt;url&gt;</span>. And a repository already in this vault is forked with the Fork button on its own page, which copies it without leaving the server.</p>
143
315
  ${fallback}
144
316
  ${back}
145
317
  </div>`;
146
- return (0, views_1.layout)('Import a repository', content, { viewer, path: '/import' });
318
+ return (0, views_1.layout)('Import or fork a repository', content, { viewer, path: '/import' });
319
+ }
320
+ /**
321
+ * The sync page, reached from the "forked from" line in the repository
322
+ * header. Like importing, syncing runs on the reader's machine rather than on
323
+ * the server, which holds no credential for the upstream and never fetches
324
+ * from another host; so, like the import page, this one hands them the
325
+ * command that does it, filled in with this repository's address.
326
+ */
327
+ function syncPage(ctx, vaultUrl) {
328
+ const base = (0, views_1.repoUrl)(ctx);
329
+ const up = ctx.upstream;
330
+ const branch = ctx.defaultBranch
331
+ ? (0, html_1.html) `That fast-forwards <span class="mono">${ctx.defaultBranch}</span>; <span class="mono">--branch</span> names another.`
332
+ : (0, html_1.html) `This repository is empty, so name the branch to sync with <span class="mono">--branch</span>.`;
333
+ const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'code')}
334
+ <div class="form-box wide">
335
+ <h1>Sync from the upstream</h1>
336
+ <p class="muted">This repository was forked from ${up.web ? (0, html_1.html) `<a href="${up.web}" rel="noopener">${up.label}</a>` : (0, html_1.html) `<span class="mono">${up.label}</span>`}. Syncing runs on your machine, not on this server: git fetches the upstream with the credentials you already have there and pushes the result here, so the vault never needs a credential for the upstream. The <span class="mono">mochi</span> command does both.</p>
337
+ <hr class="rule">
338
+ <h2>Once per machine</h2>
339
+ ${(0, views_1.copyRow)('npm install -g @magland/mochi')}
340
+ ${(0, views_1.copyRow)(`mochi login ${vaultUrl}`)}
341
+ <h2>Then, whenever the upstream has moved</h2>
342
+ ${(0, views_1.copyRow)(`mochi sync ${ctx.collection}/${ctx.repo}`)}
343
+ <p class="muted small">${branch} Only a fast-forward is ever pushed: a branch that has grown commits of its own is reported as diverged and left alone, since reconciling it is a merge or a rebase in a clone of your own. The upstream URL is the one recorded in <a href="${base}/settings">settings</a>.</p>
344
+ <p><a class="btn" href="${base}">Back to ${ctx.repo}</a></p>
345
+ </div>`;
346
+ return (0, views_1.layout)(`Sync ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, `${base}/sync`));
147
347
  }
148
348
  /**
149
349
  * Creating a collection on its own. A push creates the collection it lands in,
@@ -166,18 +366,45 @@ ${(0, views_1.csrfField)(viewer)}
166
366
  return (0, views_1.layout)('New collection', content, { viewer, path: '/new/collection' });
167
367
  }
168
368
  /**
169
- * A collection's own settings, which today is one operation: its name.
369
+ * A collection's own settings: its owners, its site alias, its name, and its
370
+ * deletion.
170
371
  *
171
372
  * Repository settings live under the repository, so a collection's live under
172
373
  * the collection, at the same place in the path. The rename is graded amber
173
374
  * rather than red for the same reason a repository's is: what it breaks can be
174
- * put back by renaming it again.
375
+ * put back by renaming it again. Deletion takes the red grade and the typed
376
+ * confirmation, and is offered only when the collection is empty; one holding
377
+ * repositories shows why the form is absent instead.
175
378
  */
176
- function collectionSettingsPage(viewer, collection, repoCount, owners, msg, error) {
379
+ function collectionSettingsPage(viewer, collection, repoCount, owners, sites, msg, error) {
177
380
  const base = `/${encodeURIComponent(collection)}`;
178
381
  const holds = repoCount === 0
179
382
  ? 'This collection is empty.'
180
383
  : `Every one of its ${repoCount === 1 ? 'repository' : `${repoCount} repositories`} moves with it, along with their sites, workflow runs, issues, pull requests, releases, and LFS objects.`;
384
+ // The alias only means anything on a vault with a sites host, so the box is
385
+ // absent without one rather than offering a setting with no effect.
386
+ const aliasState = sites.alias
387
+ ? (0, html_1.html) `Sites in this collection are served from <span class="mono">&lt;repo&gt;--${sites.alias}.${sites.host}</span>${sites.stored
388
+ ? (0, html_1.html) `, under the alias set below.`
389
+ : sites.alias === collection
390
+ ? (0, html_1.html) `. That is the collection's own name, which is usable as a hostname label.`
391
+ : (0, html_1.html) `. That is the name rewritten as a hostname label, the name itself not being usable as one; no other collection holds that label, which is what makes it available here. An alias set below replaces it.`}`
392
+ : sites.taken
393
+ ? (0, html_1.html) `This collection's name is not usable as a hostname label, and the label it would be rewritten to, <span class="mono">${sites.taken}</span>, ${sites.takenBy
394
+ ? (0, html_1.html) `belongs to the collection <a href="/${sites.takenBy}">${sites.takenBy}</a>`
395
+ : (0, html_1.html) `is one another collection's name rewrites to as well, so it belongs to neither`}. Until an alias is set here, nothing in this collection has a derived hostname, and its sites are served on the forge host, sandboxed, unless a repository claims a label of its own.`
396
+ : (0, html_1.html) `This collection's name is not usable as a hostname label, so nothing in it has a derived hostname until an alias is set here; its sites are served on the forge host, sandboxed, unless a repository claims a label of its own.`;
397
+ const sitesBox = sites.host
398
+ ? (0, html_1.html) `<div class="box settings-box" id="site"><div class="box-header">${(0, icons_1.icon)('globe')}Site alias</div><div class="box-body">
399
+ <p>${aliasState}</p>
400
+ <form method="post" action="${base}/settings/site">
401
+ ${(0, views_1.csrfField)(viewer)}
402
+ <div class="field"><label for="siteAlias">Hostname label</label><input type="text" id="siteAlias" name="alias" value="${sites.stored}" placeholder="${sites.alias ?? (0, siteshost_1.sanitizedSiteLabel)(collection)}" autocomplete="off" spellcheck="false">
403
+ <p class="muted small">The label standing in for <span class="mono">${collection}</span> in each of its repositories' hostnames: lowercase letters, digits, and single interior hyphens, and never a doubled hyphen. Empty means the collection's own name where that is usable as a label, and otherwise its name rewritten as one. Changing it moves every site in the collection to a new origin; the old hostnames stop resolving rather than redirecting, since the alias they carry then names no collection.</p></div>
404
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
405
+ </form>
406
+ </div></div>`
407
+ : '';
181
408
  const content = (0, html_1.html) `<div class="page-head"><h1 class="with-avatar">${(0, avatar_1.avatar)(collection, 28, 'square')}${collection}</h1></div>
182
409
  <h2>Settings</h2>
183
410
  ${flashBanner(msg)}
@@ -187,7 +414,7 @@ ${errorBanner(error)}
187
414
  and manage these settings. A user named <span class="mono">${collection}</span> owns it by bearing its name and is not
188
415
  listed here.</p>
189
416
  ${owners.length
190
- ? (0, html_1.html) `<table class="listing"><tbody><tr><th>User</th><th class="right"></th></tr>${owners.map((o) => (0, html_1.html) `<tr><td class="with-avatar">${(0, avatar_1.avatar)(o, 24)}<b>${o}</b></td><td class="right">
417
+ ? (0, html_1.html) `<table class="listing"><tbody><tr><th>User</th><th class="right"></th></tr>${owners.map((o) => (0, html_1.html) `<tr><td class="with-avatar">${(0, views_1.userLink)(o, { face: 24, bold: true })}</td><td class="right">
191
418
  <form method="post" action="${base}/settings/owners/remove" class="inline-form">
192
419
  ${(0, views_1.csrfField)(viewer)}
193
420
  <input type="hidden" name="username" value="${o}">
@@ -200,6 +427,7 @@ ${(0, views_1.csrfField)(viewer)}
200
427
  <button type="submit" class="btn">${(0, icons_1.icon)('people')}<span>Add owner</span></button>
201
428
  </form>
202
429
  </div></div>
430
+ ${sitesBox}
203
431
  <div class="danger-zone caution">
204
432
  <h3>Rename</h3>
205
433
  <p>${holds} The old address is redirected here, so links and existing clones keep working until something else is created under that name, but token scopes naming the old collection have to be granted again under the new name.</p>
@@ -208,6 +436,17 @@ ${(0, views_1.csrfField)(viewer)}
208
436
  <label for="toName">Collection name</label><input type="text" id="toName" name="name" value="${collection}" required>
209
437
  <button type="submit" class="btn">${(0, icons_1.icon)('pencil')}<span>Rename</span></button>
210
438
  </form>
439
+ </div>
440
+ <div class="danger-zone">
441
+ <h3>Danger zone</h3>
442
+ ${repoCount === 0
443
+ ? (0, html_1.html) `<p>Deleting this collection removes its directory and its owners list from the vault permanently. There is no undo.</p>
444
+ <form method="post" action="${base}/settings/delete">
445
+ ${(0, views_1.csrfField)(viewer)}
446
+ <div class="field"><label for="confirm">Type <b class="mono">${collection}</b> to confirm</label><input type="text" id="confirm" name="confirm" autocomplete="off"></div>
447
+ <button type="submit" class="btn btn-danger">${(0, icons_1.icon)('trash')}<span>Delete this collection</span></button>
448
+ </form>`
449
+ : (0, html_1.html) `<p>A collection can be deleted only once it is empty. This one holds ${repoCount === 1 ? 'a repository' : `${repoCount} repositories`}; delete ${repoCount === 1 ? 'it' : 'them'}, or move ${repoCount === 1 ? 'it' : 'them'} to another collection, first.</p>`}
211
450
  </div>`;
212
451
  return (0, views_1.layout)(`Settings - ${collection}`, content, {
213
452
  crumbs: (0, html_1.html) ` / <a href="${base}">${collection}</a>`,
@@ -243,12 +482,26 @@ function editFilePage(ctx, filePath, content, expectedHead, error) {
243
482
  const action = `${base}/edit/${(0, views_1.encPath)(ctx.ref)}/${(0, views_1.encPath)(filePath)}`;
244
483
  const cancel = `${base}/blob/${(0, views_1.encPath)(ctx.ref)}/${(0, views_1.encPath)(filePath)}`;
245
484
  const rows = Math.min(30, Math.max(12, content.split('\n').length + 2));
485
+ // A markdown file gets the markdown editor's tabs and toolbar around the
486
+ // same field, with the preview resolving relative links against the file's
487
+ // own branch and directory; any other file gets the plain editor.
488
+ const editor = (0, markdown_1.isMarkdownFile)(filePath)
489
+ ? (0, mdedit_1.markdownEditor)({
490
+ name: 'content',
491
+ rows,
492
+ value: content,
493
+ codeEditor: true,
494
+ preview: (0, mdedit_1.previewUrl)(ctx),
495
+ ref: ctx.ref,
496
+ dir: filePath.split('/').slice(0, -1).join('/'),
497
+ })
498
+ : (0, html_1.html) `<textarea class="code-editor" name="content" rows="${rows}" spellcheck="false">${content}</textarea>`;
246
499
  const body = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'code')}
247
500
  <h2 class="file-head">Editing <span class="mono">${filePath}</span> on <span class="mono">${ctx.ref}</span></h2>
248
501
  ${errorBanner(error)}
249
502
  <form method="post" action="${action}">
250
503
  <div class="field"><label for="path">Path</label><input type="text" id="path" name="path" value="${filePath}" spellcheck="false"><p class="muted small">Changing it renames or moves the file in the same commit.</p></div>
251
- <textarea class="code-editor" name="content" rows="${rows}" spellcheck="false">${content}</textarea>
504
+ ${editor}
252
505
  <div class="commit-box">
253
506
  ${commitFields(ctx.viewer, expectedHead, `Update ${filePath.split('/').pop()}`, ctx.ref)}
254
507
  <div class="actions"><button type="submit" class="btn btn-primary">Commit changes</button><a class="btn" href="${cancel}">Cancel</a></div>
@@ -256,6 +509,63 @@ ${commitFields(ctx.viewer, expectedHead, `Update ${filePath.split('/').pop()}`,
256
509
  </form>`;
257
510
  return (0, views_1.layout)(`Editing ${filePath} - ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx));
258
511
  }
512
+ /**
513
+ * The editor for an age-encrypted file. The server cannot fill the textarea,
514
+ * so the page opens on a passphrase form instead: /assets/age.js fetches the
515
+ * ciphertext, decrypts it in the page, and only then reveals the editor. The
516
+ * commit posts fresh ciphertext through the same route and guards as any
517
+ * other edit; the unlock form stands outside the commit form (forms do not
518
+ * nest), and its passphrase input carries no name, so nothing can post it.
519
+ * The optional new-passphrase pair re-keys the file on commit; those inputs
520
+ * are nameless for the same reason.
521
+ */
522
+ function editAgeFilePage(ctx, filePath, expectedHead, error) {
523
+ const base = (0, views_1.repoUrl)(ctx);
524
+ const action = `${base}/edit/${(0, views_1.encPath)(ctx.ref)}/${(0, views_1.encPath)(filePath)}`;
525
+ const cancel = `${base}/blob/${(0, views_1.encPath)(ctx.ref)}/${(0, views_1.encPath)(filePath)}`;
526
+ const rawUrl = `${base}/raw/${(0, views_1.encPath)(ctx.ref)}/${(0, views_1.encPath)(filePath)}`;
527
+ const body = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'code')}
528
+ <h2 class="file-head">Editing <span class="mono">${filePath}</span> on <span class="mono">${ctx.ref}</span></h2>
529
+ ${errorBanner(error)}
530
+ <div class="age-card blob-binary">
531
+ <p class="age-head">${(0, icons_1.icon)('lock')}<b>Encrypted with age</b></p>
532
+ <p class="muted small">The passphrase decrypts the file here, in this page, and encrypts it again when you commit. It is sent nowhere.</p>
533
+ <form class="age-unlock" data-age-for-edit>${(0, views_1.agePassInput)({
534
+ placeholder: 'Passphrase',
535
+ autocomplete: 'off',
536
+ required: true,
537
+ })}<button type="submit" class="btn btn-primary" data-busy-label="Decrypting&hellip;">Decrypt</button></form>
538
+ <p class="age-error form-error" hidden></p>
539
+ <noscript><p class="muted small">Editing in the browser needs JavaScript. Without it, edit a clone with the age CLI.</p></noscript>
540
+ </div>
541
+ <form method="post" action="${action}" data-age-edit data-age-raw="${rawUrl}" hidden>
542
+ <p class="age-bar-note">${(0, icons_1.icon)('lock')}<span>Decrypted in your browser. Committing encrypts it again before anything leaves this page.</span></p>
543
+ <div class="field"><label for="path">Path</label><input type="text" id="path" name="path" value="${filePath}" spellcheck="false"><p class="muted small">Changing it renames or moves the file in the same commit.</p><p class="age-warn" data-age-rename-warn hidden>${(0, icons_1.icon)('alert')}<span>The name no longer ends in <span class="mono">.age</span>: the file would still be committed encrypted, but the vault would stop treating it as an encrypted file.</span></p></div>
544
+ <textarea class="code-editor" rows="18" spellcheck="false" disabled></textarea>
545
+ <details class="age-newpass">
546
+ <summary>Change the passphrase</summary>
547
+ <div class="field"><label for="age-newpass">New passphrase</label>${(0, views_1.agePassInput)({
548
+ id: 'age-newpass',
549
+ autocomplete: 'new-password',
550
+ })}</div>
551
+ <div class="field"><label for="age-newpass2">Repeat new passphrase</label>${(0, views_1.agePassInput)({
552
+ id: 'age-newpass2',
553
+ autocomplete: 'new-password',
554
+ })}</div>
555
+ <p class="muted small">Left empty, the commit keeps the current passphrase. A forgotten passphrase is not recoverable.</p>
556
+ </details>
557
+ <input type="hidden" name="content" value="">
558
+ <p class="age-error form-error" hidden></p>
559
+ <div class="commit-box">
560
+ ${commitFields(ctx.viewer, expectedHead, `Update ${filePath.split('/').pop()}`, ctx.ref)}
561
+ <div class="actions"><button type="submit" class="btn btn-primary" disabled data-busy-label="Encrypting&hellip;">Commit changes</button><a class="btn" href="${cancel}">Cancel</a></div>
562
+ </div>
563
+ </form>`;
564
+ return (0, views_1.layout)(`Editing ${filePath} - ${ctx.collection}/${ctx.repo}`, body, {
565
+ ...(0, views_1.repoOpts)(ctx),
566
+ ageScript: true,
567
+ });
568
+ }
259
569
  function newFilePage(ctx, branch, dir, expectedHead, preset, error) {
260
570
  const base = (0, views_1.repoUrl)(ctx);
261
571
  const action = `${base}/new/${(0, views_1.encPath)(branch)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}`;
@@ -268,15 +578,30 @@ function newFilePage(ctx, branch, dir, expectedHead, preset, error) {
268
578
  <h2 class="file-head">New file in <span class="mono">${prefix || '/'}</span> on <span class="mono">${branch}</span></h2>
269
579
  ${branchNote}
270
580
  ${errorBanner(error)}
271
- <form method="post" action="${action}">
272
- <div class="field"><label for="filename">File name</label><div class="filename-row"><span class="mono muted">${prefix}</span><input type="text" id="filename" name="filename" value="${preset.filename ?? ''}" placeholder="path/to/file.md" required></div></div>
581
+ <form method="post" action="${action}" data-age-new>
582
+ <div class="field"><label for="filename">File name</label><div class="filename-row"><span class="mono muted">${prefix}</span><input type="text" id="filename" name="filename" value="${preset.filename ?? ''}" placeholder="path/to/file.md" required></div><p class="muted small" data-age-hint>A name ending in <span class="mono">.age</span> is encrypted with a passphrase in this page before it is committed.</p></div>
583
+ <div class="age-pass-fields blob-binary age-card" hidden>
584
+ <p class="age-head">${(0, icons_1.icon)('lock')}<b>A name ending in <span class="mono">.age</span> makes an encrypted file</b></p>
585
+ <p class="muted small">The content below is encrypted in this page before it is committed; the vault stores only ciphertext, and the passphrase is sent nowhere and stored nowhere. A forgotten passphrase is not recoverable.</p>
586
+ <div class="field"><label for="age-pass">Passphrase</label>${(0, views_1.agePassInput)({
587
+ id: 'age-pass',
588
+ autocomplete: 'new-password',
589
+ })}</div>
590
+ <div class="field"><label for="age-pass2">Repeat passphrase</label>${(0, views_1.agePassInput)({
591
+ id: 'age-pass2',
592
+ autocomplete: 'new-password',
593
+ })}</div>
594
+ <p class="age-error form-error" hidden></p>
595
+ </div>
273
596
  <textarea class="code-editor" name="content" rows="18" spellcheck="false">${preset.content ?? ''}</textarea>
274
597
  <div class="commit-box">
275
598
  ${commitFields(ctx.viewer, expectedHead, 'Create new file', expectedHead === null ? undefined : branch)}
276
- <div class="actions"><button type="submit" class="btn btn-primary">Commit new file</button><a class="btn" href="${cancel}">Cancel</a></div>
599
+ <div class="actions"><button type="submit" class="btn btn-primary" data-busy-label="Encrypting&hellip;">Commit new file</button><a class="btn" href="${cancel}">Cancel</a></div>
277
600
  </div>
278
601
  </form>`;
279
- return (0, views_1.layout)(`New file - ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx));
602
+ // The age script rides on every new-file page, because whether it is
603
+ // needed is decided by the name the user has yet to type.
604
+ return (0, views_1.layout)(`New file - ${ctx.collection}/${ctx.repo}`, body, { ...(0, views_1.repoOpts)(ctx), ageScript: true });
280
605
  }
281
606
  /**
282
607
  * The upload form. A file input and the same commit box as the editor, so
@@ -326,25 +651,35 @@ function conflictPage(ctx, branch, retryUrl) {
326
651
  </div>`;
327
652
  return (0, views_1.layout)(`Conflict - ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx));
328
653
  }
329
- function settingsPage(ctx, description, access, msg, error) {
654
+ function isSettingsSection(value) {
655
+ return value === 'general' || value === 'access' || value === 'site';
656
+ }
657
+ function settingsPage(ctx, description, topics, access, site, msg, error, section) {
330
658
  const base = (0, views_1.repoUrl)(ctx);
659
+ // A message belongs to the box whose form produced it; one that names no box
660
+ // (a hand-typed URL, an older link) stays at the top, where it always was.
661
+ const flashIn = (name) => (section === name ? flashBanner(msg) : '');
662
+ const errorIn = (name) => (section === name ? errorBanner(error) : '');
331
663
  const branchOptions = ctx.branches.map((b) => (0, html_1.html) `<option value="${b.name}"${b.name === ctx.defaultBranch ? (0, html_1.raw)(' selected') : ''}>${b.name}</option>`);
332
664
  const defaultBranchField = ctx.branches.length > 0
333
665
  ? (0, html_1.html) `<div class="field"><label for="defaultBranch">Default branch</label><select id="defaultBranch" name="defaultBranch">${branchOptions}</select></div>`
334
666
  : '';
335
667
  const settingsForm = ctx.canPush
336
- ? (0, html_1.html) `<div class="box settings-box"><div class="box-header">${(0, icons_1.icon)('sliders')}General</div><div class="box-body">
668
+ ? (0, html_1.html) `<div class="box settings-box" id="general"><div class="box-header">${(0, icons_1.icon)('sliders')}General</div><div class="box-body">
669
+ ${flashIn('general')}${errorIn('general')}
337
670
  <form method="post" action="${base}/settings">
338
671
  ${(0, views_1.csrfField)(ctx.viewer)}
339
672
  <div class="field"><label for="description">Description</label><input type="text" id="description" name="description" value="${description}"><p class="muted small">Shown beside the repository in listings and in the About panel.</p></div>
673
+ <div class="field"><label for="topics">Topics</label><input type="text" id="topics" name="topics" value="${topics.join(' ')}" placeholder="webgpu numbl mri"><p class="muted small">Separated by spaces or commas: lowercase letters, digits, and hyphens, up to 20. Listings can be narrowed by topic, and each topic has a page across the vault.</p></div>
340
674
  ${defaultBranchField}
675
+ <div class="field"><label for="upstream">Upstream</label><input type="text" id="upstream" name="upstream" value="${ctx.upstream?.url ?? ''}" placeholder="https://github.com/owner/repo"><p class="muted small">The URL outside this vault this repository was forked from: the header shows it as &ldquo;forked from&rdquo;, <span class="mono">mochi sync</span> fast-forwards from it, and <span class="mono">mochi pr export</span> sends pull requests back to it. An https or ssh git URL; leave empty for a repository that stands on its own.</p></div>
341
676
  <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
342
677
  </form>
343
678
  </div></div>`
344
679
  : '';
345
680
  // Who may see and write the repository. Admin only, like the rename below:
346
681
  // access is the repository's own business.
347
- const collaboratorRows = access.collaborators.map(({ username, role }) => (0, html_1.html) `<tr><td class="with-avatar">${(0, avatar_1.avatar)(username, 24)}<b>${username}</b></td><td class="muted">${role}</td><td class="right">
682
+ const collaboratorRows = access.collaborators.map(({ username, role }) => (0, html_1.html) `<tr><td class="with-avatar">${(0, views_1.userLink)(username, { face: 24, bold: true })}</td><td class="muted">${role}</td><td class="right">
348
683
  <form method="post" action="${base}/settings/collaborators/remove" class="inline-form">
349
684
  ${(0, views_1.csrfField)(ctx.viewer)}
350
685
  <input type="hidden" name="username" value="${username}">
@@ -354,7 +689,8 @@ ${(0, views_1.csrfField)(ctx.viewer)}
354
689
  ? (0, html_1.html) `<p class="muted small">Owners of <span class="mono">${ctx.collection}</span> (${(0, html_1.joinHtml)(access.owners.map((o) => (0, html_1.html) `<b>${o}</b>`), ', ')}, and the user the collection is named after) hold the admin role here without being listed.</p>`
355
690
  : (0, html_1.html) `<p class="muted small">The user the collection is named after, and any owners added to it, hold the admin role here without being listed.</p>`;
356
691
  const accessBox = ctx.canAdmin
357
- ? (0, html_1.html) `<div class="box settings-box"><div class="box-header">${(0, icons_1.icon)('people')}Access</div><div class="box-body">
692
+ ? (0, html_1.html) `<div class="box settings-box" id="access"><div class="box-header">${(0, icons_1.icon)('people')}Access</div><div class="box-body">
693
+ ${flashIn('access')}${errorIn('access')}
358
694
  <form method="post" action="${base}/settings/visibility">
359
695
  ${(0, views_1.csrfField)(ctx.viewer)}
360
696
  <input type="hidden" name="private" value="${ctx.isPrivate ? 'false' : 'true'}">
@@ -382,6 +718,65 @@ ${(0, views_1.csrfField)(ctx.viewer)}
382
718
  <button type="submit" class="btn">${(0, icons_1.icon)('people')}<span>Add</span></button>
383
719
  </form>
384
720
  <p class="muted small">read may see a private repository; write may also push and edit; admin may also change its settings, visibility, and collaborators.</p>
721
+ </div></div>`
722
+ : '';
723
+ // The site: whether the sibling directory is published, how it is written,
724
+ // and at which hostname. Admin only, like visibility, because enabling a
725
+ // site publishes whatever the directory holds to everyone.
726
+ const derivedLabel = site.derivedLabel;
727
+ const siteStatus = site.enabled
728
+ ? site.dirExists
729
+ ? (0, html_1.html) `This repository's site is <b>enabled</b>, served at <a href="${ctx.siteUrl}">${ctx.siteUrl}</a>.`
730
+ : site.source === 'repository'
731
+ ? (0, html_1.html) `This repository's site is <b>enabled</b> and follows the default branch, but nothing has been published yet: push to the branch, or save the settings below to publish it now.`
732
+ : (0, html_1.html) `This repository's site is <b>enabled</b>, but there are no site files yet: copy them into <span class="mono">${ctx.repo}.site</span> next to the repository, or have a workflow deploy them.`
733
+ : site.dirExists
734
+ ? (0, html_1.html) `This repository's site is <b>disabled</b>. The files in <span class="mono">${ctx.repo}.site</span> stay on disk but are not served; enabling the site brings them straight back.`
735
+ : (0, html_1.html) `This repository's site is <b>disabled</b>. Enabling it serves static files from a <span class="mono">${ctx.repo}.site</span> directory next to the repository, with an index.html at its root.`;
736
+ // The domain is editable in place only by a site admin; everyone else sees
737
+ // where it answers, or who to ask.
738
+ const viewerIsSiteAdmin = ctx.viewer !== null && (0, perms_1.isSiteAdmin)(ctx.viewer.auth);
739
+ const domainNote = site.domain
740
+ ? (0, html_1.html) `<p>It answers at <a href="https://${site.domain}/">https://${site.domain}/</a>, its custom domain; its other hostnames redirect there.</p>`
741
+ : viewerIsSiteAdmin
742
+ ? ''
743
+ : (0, html_1.html) `<p class="muted small">A vault admin can attach a custom domain, in this box or with <span class="mono">mochi repo edit ${ctx.collection}/${ctx.repo} --site-domain docs.example.org</span>.</p>`;
744
+ const domainField = viewerIsSiteAdmin
745
+ ? (0, html_1.html) `<div class="field"><label for="siteDomain">Custom domain</label><input type="text" id="siteDomain" name="domain" value="${site.domain ?? ''}" placeholder="docs.example.org"><p class="muted small">A hostname of the site's own; empty detaches it. Point its DNS record at this vault and cover it with a certificate yourself. It becomes the site's canonical origin, and the site's other hostnames redirect there. Site admins only, since the name is the operator's to answer for.</p></div>`
746
+ : '';
747
+ const labelField = site.sitesHost
748
+ ? (0, html_1.html) `<div class="field"><label for="siteLabel">Hostname label</label><input type="text" id="siteLabel" name="label" value="${site.label}" placeholder="${derivedLabel ?? ''}"><p class="muted small">The label under <span class="mono">${site.sitesHost}</span> the site is served from: lowercase letters, digits, and single interior hyphens. ${derivedLabel
749
+ ? (0, html_1.html) `Empty means the default, <span class="mono">${derivedLabel}</span>.`
750
+ : site.collectionAlias === null
751
+ ? (0, html_1.html) `The collection <span class="mono">${ctx.collection}</span> has no site alias, so nothing in it has a derived hostname; an owner can give it one in the <a href="/${ctx.collection}/settings">collection's settings</a>. Without a label here the site is served on the forge host, sandboxed.`
752
+ : (0, html_1.html) `This repository's name is not usable as a hostname label, so without one the site is served on the forge host, sandboxed.`}</p></div>`
753
+ : '';
754
+ const siteConfigForm = site.enabled
755
+ ? (0, html_1.html) `<hr class="rule">
756
+ <form method="post" action="${base}/settings/site">
757
+ ${(0, views_1.csrfField)(ctx.viewer)}
758
+ <div class="field"><label for="siteSource">Published by</label><select id="siteSource" name="source">
759
+ <option value="copy"${site.source === 'copy' ? (0, html_1.raw)(' selected') : ''}>Copied files</option>
760
+ <option value="actions"${site.source === 'actions' ? (0, html_1.raw)(' selected') : ''}>Workflow deploys</option>
761
+ <option value="repository"${site.source === 'repository' ? (0, html_1.raw)(' selected') : ''}>Repository contents</option>
762
+ </select><p class="muted small">Copied files means whatever can write the vault publishes by writing the directory, and a workflow's deploy step is refused. Workflow deploys additionally lets a run's <span class="mono">deploy-pages</span> step publish the site. Repository contents means the server publishes the default branch itself, again on every push that moves it; a <span class="mono">.gitattributes</span> line such as <span class="mono">notes.txt export-ignore</span> keeps a file out.</p></div>
763
+ <div class="field"><label for="sitePath">Directory</label><input id="sitePath" name="path" value="${site.path}" placeholder="(root of the repository)"><p class="muted small">For repository contents: the directory within the branch to publish, such as <span class="mono">docs</span>. Empty publishes the whole tree.</p></div>
764
+ ${labelField}
765
+ ${domainField}
766
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
767
+ </form>`
768
+ : '';
769
+ const siteBox = ctx.canAdmin
770
+ ? (0, html_1.html) `<div class="box settings-box" id="site"><div class="box-header">${(0, icons_1.icon)('globe')}Site</div><div class="box-body">
771
+ ${flashIn('site')}${errorIn('site')}
772
+ <p>${siteStatus}</p>
773
+ ${site.enabled ? domainNote : ''}
774
+ <form method="post" action="${base}/settings/site">
775
+ ${(0, views_1.csrfField)(ctx.viewer)}
776
+ <input type="hidden" name="enabled" value="${site.enabled ? 'false' : 'true'}">
777
+ <button type="submit" class="btn">${site.enabled ? 'Disable site' : 'Enable site'}</button>
778
+ </form>
779
+ ${siteConfigForm}
385
780
  </div></div>`
386
781
  : '';
387
782
  // Renaming and moving are one operation, since both are a directory rename.
@@ -398,6 +793,7 @@ ${(0, views_1.csrfField)(ctx.viewer)}
398
793
  <label for="toName">Name</label><input type="text" id="toName" name="name" value="${ctx.repo}" required>
399
794
  <button type="submit" class="btn">${(0, icons_1.icon)('pencil')}<span>Rename</span></button>
400
795
  </form>
796
+ <p class="muted small">The collection may be one that exists or a new one to create along with the move, so check it for typos: a misspelt collection is created, not refused.</p>
401
797
  </div>`
402
798
  : '';
403
799
  const dangerZone = ctx.canAdmin
@@ -413,14 +809,44 @@ ${(0, views_1.csrfField)(ctx.viewer)}
413
809
  : '';
414
810
  const body = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'settings')}
415
811
  <h2>Settings</h2>
416
- ${flashBanner(msg)}
417
- ${errorBanner(error)}
812
+ ${section === undefined ? flashBanner(msg) : ''}
813
+ ${section === undefined ? errorBanner(error) : ''}
418
814
  ${settingsForm}
419
815
  ${accessBox}
816
+ ${siteBox}
420
817
  ${renameForm}
421
818
  ${dangerZone}`;
422
819
  return (0, views_1.layout)(`Settings - ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx, `${base}/settings`));
423
820
  }
821
+ /**
822
+ * Where a user writes the profile their page at /<username> shows: a display
823
+ * name, a short bio, and links. The longer-form introduction is the profile
824
+ * README (.mochi/profile/README.md in their collection), which is a file in a
825
+ * repository rather than a form here, so this page only points at it.
826
+ */
827
+ function profileSettingsPage(viewer, profile, msg, error) {
828
+ const name = viewer.auth.username;
829
+ const links = (profile?.links ?? []).join('\n');
830
+ const content = (0, html_1.html) `<div class="page-head"><h1 class="with-avatar">${(0, avatar_1.avatar)(name, 32)}Your profile</h1><span class="right-group"><a class="btn" href="/${encodeURIComponent(name)}">${(0, icons_1.icon)('person')}<span>View profile</span></a></span></div>
831
+ ${flashBanner(msg)}
832
+ ${errorBanner(error)}
833
+ <div class="form-box wide">
834
+ <form method="post" action="/settings/profile">
835
+ ${(0, views_1.csrfField)(viewer)}
836
+ <div class="field"><label for="displayName">Name</label><input type="text" id="displayName" name="displayName" value="${profile?.name ?? ''}" maxlength="80" placeholder="${name}">
837
+ <p class="muted small">Shown on your profile beside your username, <span class="mono">${name}</span>.</p></div>
838
+ <div class="field"><label for="bio">Bio</label><textarea id="bio" name="bio" rows="3" maxlength="500" placeholder="A sentence or two about yourself">${profile?.bio ?? ''}</textarea></div>
839
+ <div class="field"><label for="links">Links</label><textarea id="links" name="links" rows="3" placeholder="https://example.org">${links}</textarea>
840
+ <p class="muted small">One per line, up to five, each starting with <span class="mono">http://</span> or
841
+ <span class="mono">https://</span>.</p></div>
842
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save profile</span></button>
843
+ </form>
844
+ <hr class="rule">
845
+ <p class="muted small">For a longer introduction, your profile page also renders
846
+ <span class="mono">${name}/.mochi/profile/README.md</span>, edited like any other file in a repository.</p>
847
+ </div>`;
848
+ return (0, views_1.layout)('Your profile', content, { viewer, path: '/settings/profile' });
849
+ }
424
850
  function adminUsersPage(viewer, users, msg, error) {
425
851
  const rows = users.map(({ name, user }) => {
426
852
  const actions = (0, html_1.html) `<details class="dropdown"><summary class="btn">${(0, icons_1.icon)('kebab')}<span>Manage</span></summary><div class="dropdown-menu dd-right user-actions">
@@ -509,6 +935,40 @@ repository (collaborators), per collection (owners), or with the site-admin bit
509
935
  <button type="submit" class="btn">${user.siteAdmin ? 'Withdraw site admin' : 'Make site admin'}</button>
510
936
  </form>
511
937
  </div>`;
938
+ // Passkeys sit beside tokens because they are the same kind of thing -- a
939
+ // credential this user signs in with -- and revoking one is the same
940
+ // emergency. Adding one is not here: registration takes the authenticator,
941
+ // which is on the user's own device, so it lives on their /account page.
942
+ const passkeyRows = (user.passkeys ?? []).map((p) => (0, html_1.html) `<tr><td>${p.name ?? 'Passkey'}</td><td class="muted small">${p.created ? (0, render_1.timeTag)(p.created, '') : ''}</td><td class="mono small muted">${p.id.slice(0, 8)}&hellip;</td><td class="right">
943
+ <form method="post" action="${base}/passkeys/${encodeURIComponent(p.id)}/delete" class="inline-form">
944
+ ${(0, views_1.csrfField)(viewer)}
945
+ <button type="submit" class="btn btn-danger-outline">Remove</button>
946
+ </form></td></tr>`);
947
+ const passkeys = passkeyRows.length
948
+ ? (0, html_1.html) `<h2>Passkeys</h2>
949
+ <table class="listing"><tbody><tr><th>Passkey</th><th>Added</th><th>Id</th><th class="right"></th></tr>${passkeyRows}</tbody></table>
950
+ <p class="muted small">Passkeys sign in to the web interface only, with the user's full standing. ${name} adds them from
951
+ their own account page; removal is immediate, like revoking a token.</p>`
952
+ : '';
953
+ // The GitHub link sits with the other credentials for the same reason
954
+ // passkeys do: it is a way this user signs in, and unlinking is the same
955
+ // emergency off switch. Linking is not here; it takes the GitHub side, so
956
+ // it lives on the user's own account page.
957
+ const github = user.github
958
+ ? (0, html_1.html) `<div class="form-box">
959
+ <h2>GitHub</h2>
960
+ <form method="post" action="${base}/github/unlink">
961
+ ${(0, views_1.csrfField)(viewer)}
962
+ <p>Linked to GitHub account <a href="https://github.com/${user.github.login}"><b>${user.github.login}</b></a>
963
+ <span class="mono small muted">id ${String(user.github.id)}</span>. &ldquo;Sign in with GitHub&rdquo; signs in as ${name},
964
+ with their full standing.</p>
965
+ <button type="submit" class="btn btn-danger-outline">Unlink</button>
966
+ </form>
967
+ <p class="muted small">Unlinking ends sessions signed in with GitHub on their next request. To bar this person from
968
+ GitHub sign-in entirely, also remove them from the approved list under <a href="/admin/github">GitHub sign-in</a>, or a
969
+ fresh account would be created for them on their next sign-in.</p>
970
+ </div>`
971
+ : '';
512
972
  const identity = (0, html_1.html) `<div class="form-box">
513
973
  <h2>Identity</h2>
514
974
  <form method="post" action="${base}/emails">
@@ -533,13 +993,15 @@ ${(0, views_1.csrfField)(viewer)}
533
993
  <button type="submit" class="btn btn-danger">${(0, icons_1.icon)('trash')}<span>Delete this user</span></button>
534
994
  </form>
535
995
  </div>`;
536
- const content = (0, html_1.html) `<div class="page-head"><div class="with-avatar">${(0, avatar_1.avatar)(name, 32)}<h1>${name}</h1></div></div>
996
+ const content = (0, html_1.html) `<div class="page-head"><div class="with-avatar">${(0, avatar_1.avatar)(name, 32)}<h1>${name}</h1></div><span class="right-group"><a class="btn" href="/${encodeURIComponent(name)}">${(0, icons_1.icon)('person')}<span>Profile</span></a></span></div>
537
997
  ${flashBanner(msg)}
538
998
  ${errorBanner(error)}
539
999
  <p class="muted">${user.siteAdmin ? 'Site admin' : (0, html_1.html) `Owns the collection <span class="mono">${name}</span> by name`}</p>
540
1000
  <h2>Tokens</h2>
541
1001
  ${tokens}
542
1002
  ${mint}
1003
+ ${passkeys}
1004
+ ${github}
543
1005
  ${grant}
544
1006
  ${identity}
545
1007
  ${danger}
@@ -559,8 +1021,10 @@ function adminShell(viewer, active, title, path, body) {
559
1021
  const nav = (0, html_1.html) `<aside class="admin-side"><div class="side-block"><h3>${(0, icons_1.icon)('sliders')}Administration</h3><div class="side-links">
560
1022
  ${item('users', '/admin/users', 'Users', 'people')}
561
1023
  ${item('runners', '/admin/runners', 'Runners', 'server')}
1024
+ ${canVault ? item('github', '/admin/github', 'GitHub sign-in', 'person') : ''}
562
1025
  ${canVault ? item('egress', '/admin/egress', 'Egress', 'upload') : ''}
563
1026
  ${canVault ? item('appearance', '/admin/appearance', 'Appearance', 'appearance') : ''}
1027
+ ${canVault ? item('settings', '/admin/settings', 'Vault settings', 'sliders') : ''}
564
1028
  </div></div></aside>`;
565
1029
  // The bar locates an admin page the way it locates a repository page: the
566
1030
  // trail up from here, for after the heading has scrolled away.
@@ -568,8 +1032,10 @@ ${canVault ? item('appearance', '/admin/appearance', 'Appearance', 'appearance')
568
1032
  index: null,
569
1033
  users: { href: '/admin/users', label: 'Users' },
570
1034
  runners: { href: '/admin/runners', label: 'Runners' },
1035
+ github: { href: '/admin/github', label: 'GitHub sign-in' },
571
1036
  egress: { href: '/admin/egress', label: 'Egress' },
572
1037
  appearance: { href: '/admin/appearance', label: 'Appearance' },
1038
+ settings: { href: '/admin/settings', label: 'Vault settings' },
573
1039
  };
574
1040
  const section = sections[active];
575
1041
  const crumbs = (0, html_1.html) ` / <a href="/admin">admin</a>${section ? (0, html_1.html) ` / <a href="${section.href}">${section.label}</a>` : ''}`;
@@ -585,16 +1051,22 @@ function adminIndexPage(viewer, canVault) {
585
1051
  <div class="card-list">
586
1052
  ${card('/admin/users', 'Users', 'Create users, grant site admin, mint tokens.')}
587
1053
  ${card('/admin/runners', 'Runners', 'Register the machines that execute workflow jobs.')}
1054
+ ${canVault
1055
+ ? card('/admin/github', 'GitHub sign-in', 'Let approved GitHub accounts sign in to this vault.')
1056
+ : ''}
588
1057
  ${canVault
589
1058
  ? card('/admin/egress', 'Egress', 'See what has been sent out today, per repository, and cap what a day may send.')
590
1059
  : ''}
591
1060
  ${canVault
592
1061
  ? card('/admin/appearance', 'Appearance', 'Choose the theme this vault is served with.')
593
1062
  : ''}
1063
+ ${canVault
1064
+ ? card('/admin/settings', 'Vault settings', 'The sites hostname, CI retention, forwarded headers, and rate limits.')
1065
+ : ''}
594
1066
  </div>
595
1067
  ${canVault
596
1068
  ? ''
597
- : (0, html_1.html) `<p class="muted small">Appearance and the egress budget are vault-wide settings, so they are the site admin's.</p>`}`;
1069
+ : (0, html_1.html) `<p class="muted small">Appearance, the egress budget, and the vault settings are vault-wide, so they are the site admin's.</p>`}`;
598
1070
  return adminShell(viewer, 'index', 'Administration', '/admin', content);
599
1071
  }
600
1072
  /**
@@ -720,6 +1192,205 @@ ${(0, views_1.csrfField)(viewer)}
720
1192
  </form>`;
721
1193
  return adminShell(viewer, 'appearance', 'Appearance', '/admin/appearance', content);
722
1194
  }
1195
+ function isVaultSettingsSection(value) {
1196
+ return value === 'sites' || value === 'ci' || value === 'network' || value === 'limits';
1197
+ }
1198
+ /**
1199
+ * The rest of config.json, administered: the sites hostname, CI retention,
1200
+ * and the startup-read network and limits blocks. The first two are read per
1201
+ * request, so a save is in effect on the next one. The last two are read once
1202
+ * at startup; a save still writes config.json, and the box carries an amber
1203
+ * note naming each saved value the running server is not yet using, with the
1204
+ * command that restarts it, so the state of the world is never misreported.
1205
+ */
1206
+ function vaultSettingsPage(viewer, info, msg, section) {
1207
+ // A message belongs to the box whose form produced it; one that names no box
1208
+ // (a hand-typed URL, an older link) stays at the top.
1209
+ const flashIn = (name) => (section === name ? flashBanner(msg) : '');
1210
+ const restartHow = info.restart
1211
+ ? (0, html_1.html) `restart the server: <span class="mono">${info.restart.command}</span>${info.restart.caveat ? (0, html_1.html) ` (${info.restart.caveat})` : ''}`
1212
+ : (0, html_1.html) `stop the server and start it again the way it was started`;
1213
+ const pendingNote = (rows) => rows.length
1214
+ ? (0, html_1.html) `<div class="restart-note">Saved, but not yet in effect: the running server started with ${(0, html_1.joinHtml)(rows.map((r) => (0, html_1.html) `<span class="mono">${r.name}</span> ${r.running} (saved: ${r.saved})`), ', ')}. To apply, ${restartHow}.</div>`
1215
+ : '';
1216
+ const num = (id, label, value, min, blurb) => (0, html_1.html) `<div class="field"><label for="${id}">${label}</label><input type="number" id="${id}" name="${id}" min="${String(min)}" step="1" value="${String(value)}">${blurb === '' ? '' : (0, html_1.html) `<p class="muted small">${blurb}</p>`}</div>`;
1217
+ // Who holds what under the sites host: the labels repositories have claimed,
1218
+ // which are one flat vault-wide namespace, and each collection's alias, which
1219
+ // stands in for its name in every derived hostname. Neither is editable here;
1220
+ // this is the one place both can be read at once.
1221
+ const names = info.names;
1222
+ const labelRows = (names?.labels ?? []).map(({ label, collection, repo }) => (0, html_1.html) `<tr><td class="mono">${label}.${info.sitesHost}</td><td><a href="/${collection}/${repo}/settings#site">${collection}/${repo}</a></td></tr>`);
1223
+ const aliasRows = (names?.collections ?? []).map(({ collection, alias, stored, taken }) => (0, html_1.html) `<tr><td class="mono">${alias ? (0, html_1.html) `&lt;repo&gt;--${alias}.${info.sitesHost}` : (0, html_1.html) `<span class="muted">none</span>`}</td><td><a href="/${collection}/settings#site">${collection}</a>${stored
1224
+ ? (0, html_1.raw)(' <span class="counter">set</span>')
1225
+ : taken
1226
+ ? (0, html_1.html) ` <span class="muted small">(<span class="mono">${taken}</span> is taken)</span>`
1227
+ : ''}</td></tr>`);
1228
+ const namesUnderHost = names
1229
+ ? (0, html_1.html) `<hr class="rule">
1230
+ <h3>Claimed labels</h3>
1231
+ ${labelRows.length
1232
+ ? (0, html_1.html) `<table class="listing"><tbody><tr><th>Hostname</th><th>Repository</th></tr>${labelRows}</tbody></table>`
1233
+ : (0, html_1.html) `<p class="muted">No repository has claimed a label; every site is served under its derived hostname.</p>`}
1234
+ <p class="muted small">A label is a single name under the sites host, claimed by a repository's admin in its own settings, and one repository at a time holds it. These labels cannot collide with a derived hostname, which always carries a doubled hyphen. ${names.reserved.length
1235
+ ? (0, html_1.html) `Reserved, so no repository may claim them: ${(0, html_1.joinHtml)(names.reserved.map((r) => (0, html_1.html) `<span class="mono">${r}</span>`), ', ')}.`
1236
+ : ''}</p>
1237
+ <h3>Collection aliases</h3>
1238
+ ${aliasRows.length
1239
+ ? (0, html_1.html) `<table class="listing"><tbody><tr><th>Derived hostname</th><th>Collection</th></tr>${aliasRows}</tbody></table>`
1240
+ : (0, html_1.html) `<p class="muted">No collections yet.</p>`}
1241
+ <p class="muted small">The alias stands in for a collection's name in each of its repositories' derived hostnames. Without one stored, it is the collection's own name where that is usable as a hostname label, and otherwise the name rewritten as one; a rewritten label that another collection already holds is left unused, and an owner resolves it by storing an alias in the collection's settings. A collection with no alias has no derived hostnames, and its sites stay on the forge host unless each repository claims a label.</p>`
1242
+ : '';
1243
+ const sitesBox = (0, html_1.html) `<div class="box settings-box" id="sites"><div class="box-header">${(0, icons_1.icon)('globe')}Sites hostname</div><div class="box-body">
1244
+ ${flashIn('sites')}
1245
+ <p>${info.sitesHost
1246
+ ? (0, html_1.html) `Sites are served from their own origins under <span class="mono">${info.sitesHost}</span>.`
1247
+ : (0, html_1.html) `No sites host is set, so sites are served on the forge host, sandboxed.`}</p>
1248
+ <form method="post" action="/admin/settings/sites">
1249
+ ${(0, views_1.csrfField)(viewer)}
1250
+ <div class="field"><label for="sitesHost">Sites host</label><input type="text" id="sitesHost" name="host" value="${info.sitesHost}" placeholder="vault-sites.example.org" autocomplete="off" spellcheck="false">
1251
+ <p class="muted small">Subdomains of this hostname serve each eligible repository's site, at <span class="mono">&lt;repo&gt;--&lt;alias&gt;.&lt;host&gt;</span> or the label the repository chose, in place of the sandbox on the forge host. Set it only once the wildcard resolves to this vault and a certificate covers it, since sites stop being served anywhere else the moment it is saved. It is read per request, so a save is in effect immediately; empty puts sites back on the forge host, equally immediately.</p></div>
1252
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
1253
+ </form>
1254
+ ${namesUnderHost}
1255
+ </div></div>`;
1256
+ const ciBox = (0, html_1.html) `<div class="box settings-box" id="ci"><div class="box-header">${(0, icons_1.icon)('workflow')}CI retention</div><div class="box-body">
1257
+ ${flashIn('ci')}
1258
+ <form method="post" action="/admin/settings/ci">
1259
+ ${(0, views_1.csrfField)(viewer)}
1260
+ ${num('runs', 'Completed runs to keep', info.ci.runs, 0, 'Per repository; older completed runs beyond this are dropped, with their logs and artifacts.')}
1261
+ ${num('days', 'Days to keep them', info.ci.days, 0, 'Also drop completed runs older than this many days; 0 keeps them regardless of age.')}
1262
+ ${num('artifactMb', 'Largest artifact (MB)', info.ci.artifactMb, 1, 'The largest artifact a workflow job may upload.')}
1263
+ <p class="muted small">Read per request, so a change is in effect on the next one.</p>
1264
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
1265
+ </form>
1266
+ </div></div>`;
1267
+ const networkBox = (0, html_1.html) `<div class="box settings-box" id="network"><div class="box-header">${(0, icons_1.icon)('link')}Network</div><div class="box-body">
1268
+ ${flashIn('network')}
1269
+ ${pendingNote(info.trustProxy !== info.startup.trustProxy
1270
+ ? [
1271
+ {
1272
+ name: 'trustProxy',
1273
+ running: info.startup.trustProxy ? 'believed' : 'not believed',
1274
+ saved: info.trustProxy ? 'believed' : 'not believed',
1275
+ },
1276
+ ]
1277
+ : [])}
1278
+ <form method="post" action="/admin/settings/network">
1279
+ ${(0, views_1.csrfField)(viewer)}
1280
+ <div class="field"><label for="trustProxy">Forwarded headers</label><select id="trustProxy" name="trustProxy">
1281
+ <option value="true"${info.trustProxy ? (0, html_1.raw)(' selected') : ''}>Believed: a reverse proxy is the only way in</option>
1282
+ <option value="false"${info.trustProxy ? '' : (0, html_1.raw)(' selected')}>Not believed: the vault is exposed directly</option>
1283
+ </select>
1284
+ <p class="muted small">Whether <span class="mono">X-Forwarded-For</span> and <span class="mono">X-Forwarded-Proto</span> may be believed. Believe them only when a reverse proxy you control is the only way in: on a directly exposed vault they let any client claim any address, which defeats every per-address limit. Read once at startup, so a save here writes <span class="mono">config.json</span> and waits for a restart.</p></div>
1285
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
1286
+ </form>
1287
+ </div></div>`;
1288
+ const limitFields = [
1289
+ {
1290
+ key: 'requestsPerMinute',
1291
+ label: 'Requests per minute',
1292
+ min: 0,
1293
+ blurb: 'Per address, over everything not exempt; 0 turns the ceiling off.',
1294
+ },
1295
+ {
1296
+ key: 'authFailures',
1297
+ label: 'Auth failures',
1298
+ min: 0,
1299
+ blurb: 'Failed credential checks per address and username, in 15 minutes; 0 turns the limit off.',
1300
+ },
1301
+ { key: 'clone', label: 'Concurrent clones', min: 1, blurb: '' },
1302
+ { key: 'push', label: 'Concurrent pushes', min: 1, blurb: '' },
1303
+ { key: 'search', label: 'Concurrent searches', min: 1, blurb: '' },
1304
+ { key: 'tree', label: 'Concurrent tree reads', min: 1, blurb: '' },
1305
+ ];
1306
+ const limitsBox = (0, html_1.html) `<div class="box settings-box" id="limits"><div class="box-header">${(0, icons_1.icon)('filter')}Limits</div><div class="box-body">
1307
+ ${flashIn('limits')}
1308
+ ${pendingNote(limitFields
1309
+ .filter((f) => info.limits[f.key] !== info.startup.limits[f.key])
1310
+ .map((f) => ({ name: f.key, running: String(info.startup.limits[f.key]), saved: String(info.limits[f.key]) })))}
1311
+ <form method="post" action="/admin/settings/limits">
1312
+ ${(0, views_1.csrfField)(viewer)}
1313
+ ${(0, html_1.joinHtml)(limitFields.map((f) => num(f.key, f.label, info.limits[f.key], f.min, f.blurb)), '\n')}
1314
+ <p class="muted small">The four concurrencies are how many git subprocesses of each class may run at once; requests beyond them queue briefly. All of this box is read once at startup, since it holds live counters, so a save writes <span class="mono">config.json</span> and waits for a restart. The daily egress cap is the one limit not here: it is read per request, and lives on the <a href="/admin/egress">Egress</a> page.</p>
1315
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
1316
+ </form>
1317
+ </div></div>`;
1318
+ const content = (0, html_1.html) `<div class="page-head"><h1>Vault settings</h1></div>
1319
+ ${section === undefined ? flashBanner(msg) : ''}
1320
+ <p class="muted">The rest of what <span class="mono">config.json</span> holds, next to <span class="mono">vault.json</span>: every box writes that file, which can equally be edited by hand. The theme is under <a href="/admin/appearance">Appearance</a>, the daily egress cap under <a href="/admin/egress">Egress</a>, and sign-in with GitHub has a <a href="/admin/github">page of its own</a>.</p>
1321
+ ${sitesBox}
1322
+ ${ciBox}
1323
+ ${networkBox}
1324
+ ${limitsBox}`;
1325
+ return adminShell(viewer, 'settings', 'Vault settings', '/admin/settings', content);
1326
+ }
1327
+ /**
1328
+ * Sign-in with GitHub, administered: the OAuth App's credentials, the list of
1329
+ * GitHub accounts approved to sign in, and what is already linked. The
1330
+ * callback URL is on the page because it is the one value GitHub's form asks
1331
+ * for that the administrator would otherwise have to derive by hand.
1332
+ */
1333
+ function adminGithubPage(viewer, opts) {
1334
+ const on = opts.clientId !== '' && opts.secretSet;
1335
+ const status = on
1336
+ ? (0, html_1.html) `<p>Sign-in with GitHub is <b>on</b>: the sign-in page offers it, approved GitHub accounts get an account here
1337
+ on their first sign-in, and users may link their GitHub account from their account page.</p>`
1338
+ : (0, html_1.html) `<p>Sign-in with GitHub is <b>off</b>${opts.clientId !== '' ? ' until the client secret is added below' : ''}.</p>`;
1339
+ const setup = (0, html_1.html) `<div class="form-box">
1340
+ <h2>OAuth App</h2>
1341
+ ${status}
1342
+ <p class="muted small">Create a GitHub OAuth App (on GitHub: Settings &rarr; Developer settings &rarr; OAuth Apps) with
1343
+ this authorization callback URL, and put its credentials here:</p>
1344
+ ${(0, views_1.copyRow)(opts.callbackUrl)}
1345
+ <form method="post" action="/admin/github">
1346
+ ${(0, views_1.csrfField)(viewer)}
1347
+ <div class="field"><label for="ghClientId">Client id</label><input type="text" id="ghClientId" name="clientId" value="${opts.clientId}" autocomplete="off"></div>
1348
+ <div class="field"><label for="ghClientSecret">Client secret</label><input type="password" id="ghClientSecret" name="clientSecret" autocomplete="off" placeholder="${opts.secretSet ? 'stored; leave empty to keep it' : ''}">
1349
+ <p class="muted small">The client id is public and kept in <span class="mono">config.json</span>; the secret is written
1350
+ to <span class="mono">.github-secret</span> beside <span class="mono">.secret</span>, mode 600, and never shown again.
1351
+ Saving with an empty client id turns sign-in with GitHub off and removes the stored secret.</p></div>
1352
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
1353
+ </form>
1354
+ </div>`;
1355
+ const approvedRows = opts.approved.map((a) => (0, html_1.html) `<tr><td><a href="https://github.com/${a.login}">${a.login}</a></td><td class="mono small muted">${String(a.id)}</td><td class="muted small">${a.added ? (0, render_1.timeTag)(a.added, '') : ''}</td><td>${a.account
1356
+ ? (0, views_1.userLink)(a.account, { face: 20, bold: true })
1357
+ : (0, html_1.raw)('<span class="muted small">first sign-in creates one</span>')}</td><td class="right">
1358
+ <form method="post" action="/admin/github/approved/${String(a.id)}/remove" class="inline-form">
1359
+ ${(0, views_1.csrfField)(viewer)}
1360
+ <button type="submit" class="btn btn-danger-outline">Remove</button>
1361
+ </form></td></tr>`);
1362
+ const approvedTable = approvedRows.length
1363
+ ? (0, html_1.html) `<table class="listing"><tbody><tr><th>GitHub user</th><th>Id</th><th>Approved</th><th>Account here</th><th class="right"></th></tr>${approvedRows}</tbody></table>`
1364
+ : (0, html_1.html) `<p class="muted">Nobody is approved yet.</p>`;
1365
+ const approve = (0, html_1.html) `<div class="form-box">
1366
+ <h2>Approved GitHub accounts</h2>
1367
+ <p class="muted small">An approved GitHub account may sign in here; its first sign-in creates a vault account named
1368
+ after the GitHub login, holding no tokens until one is minted for it. Approval is recorded by GitHub's stable numeric
1369
+ id, resolved from the username as it is added, so a later rename on GitHub transfers nothing. Removing an approval
1370
+ stops new accounts being created; a GitHub account already linked to a user keeps signing in until it is unlinked on
1371
+ that user's page.</p>
1372
+ ${approvedTable}
1373
+ <form method="post" action="/admin/github/approve" class="inline-form">
1374
+ ${(0, views_1.csrfField)(viewer)}
1375
+ <label for="ghLogin">GitHub username</label><input type="text" id="ghLogin" name="login" autocomplete="off" spellcheck="false" required>
1376
+ <button type="submit" class="btn">${(0, icons_1.icon)('plus')}<span>Approve</span></button>
1377
+ </form>
1378
+ </div>`;
1379
+ const linkedRows = opts.linked.map((l) => (0, html_1.html) `<tr><td class="with-avatar">${(0, views_1.userLink)(l.username, { face: 20, bold: true })}</td><td><a href="https://github.com/${l.login}">${l.login}</a></td><td class="mono small muted">${String(l.id)}</td></tr>`);
1380
+ const linked = linkedRows.length
1381
+ ? (0, html_1.html) `<h2>Linked accounts</h2>
1382
+ <table class="listing"><tbody><tr><th>User</th><th>GitHub account</th><th>Id</th></tr>${linkedRows}</tbody></table>
1383
+ <p class="muted small">Each is unlinked from its user's page under <a href="/admin/users">Users</a>, or by the user on
1384
+ their own account page.</p>`
1385
+ : '';
1386
+ const content = (0, html_1.html) `<div class="page-head"><h1>GitHub sign-in</h1></div>
1387
+ ${flashBanner(opts.msg)}
1388
+ ${errorBanner(opts.error)}
1389
+ ${setup}
1390
+ ${approve}
1391
+ ${linked}`;
1392
+ return adminShell(viewer, 'github', 'GitHub sign-in', '/admin/github', content);
1393
+ }
723
1394
  function tokenPage(viewer, username, token, created) {
724
1395
  const heading = created ? `Created user ${username}` : `New token for ${username}`;
725
1396
  const content = (0, html_1.html) `<div class="form-box">